From 3e12b108667994394a7efb569243286d788049dd Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 16 Sep 2024 18:05:34 +0200 Subject: [PATCH 001/599] fix: remove bad examples of 'from' domain for emails (#12728) * fix: use example.com domain for from_address_description * fix: remove unnecessary screenshot from docs --- .../docs/administration/email-notification.mdx | 8 +++----- .../docs/administration/img/email-settings.png | Bin 222808 -> 0 bytes web/src/lib/i18n/ar.json | 2 +- web/src/lib/i18n/bg.json | 2 +- web/src/lib/i18n/ca.json | 2 +- web/src/lib/i18n/cs.json | 2 +- web/src/lib/i18n/da.json | 2 +- web/src/lib/i18n/de.json | 2 +- web/src/lib/i18n/en.json | 2 +- web/src/lib/i18n/es.json | 2 +- web/src/lib/i18n/et.json | 2 +- web/src/lib/i18n/fa.json | 2 +- web/src/lib/i18n/fi.json | 2 +- web/src/lib/i18n/he.json | 2 +- web/src/lib/i18n/hi.json | 2 +- web/src/lib/i18n/hr.json | 2 +- web/src/lib/i18n/hu.json | 2 +- web/src/lib/i18n/id.json | 2 +- web/src/lib/i18n/it.json | 2 +- web/src/lib/i18n/ja.json | 2 +- web/src/lib/i18n/ko.json | 2 +- web/src/lib/i18n/nb_NO.json | 2 +- web/src/lib/i18n/nl.json | 2 +- web/src/lib/i18n/pl.json | 2 +- web/src/lib/i18n/pt.json | 2 +- web/src/lib/i18n/pt_BR.json | 2 +- web/src/lib/i18n/ro.json | 2 +- web/src/lib/i18n/ru.json | 2 +- web/src/lib/i18n/sr_Cyrl.json | 2 +- web/src/lib/i18n/sr_Latn.json | 2 +- web/src/lib/i18n/sv.json | 2 +- web/src/lib/i18n/ta.json | 2 +- web/src/lib/i18n/th.json | 2 +- web/src/lib/i18n/tr.json | 2 +- web/src/lib/i18n/uk.json | 2 +- web/src/lib/i18n/vi.json | 2 +- web/src/lib/i18n/zh_Hant.json | 2 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 2 +- 38 files changed, 39 insertions(+), 41 deletions(-) delete mode 100644 docs/docs/administration/img/email-settings.png diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 4a2a0b5a83..93b1051053 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve ## SMTP settings -You can access the settings panel from the web at `Administration -> Settings -> Notification settings` +You can access the settings panel from the web at `Administration -> Settings -> Notification settings`. -Under Email, enter the following details to connect with SMTP servers. +Under Email, enter the required details to connect with an SMTP server. -You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. - - +You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. ## User's notifications settings diff --git a/docs/docs/administration/img/email-settings.png b/docs/docs/administration/img/email-settings.png deleted file mode 100644 index a0d71354267fba88f6194cadac1b5912d82fb493..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 222808 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%0DA8$(FY-(vXtEBy1H3YA~Ix#1WX?9 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\"", + "notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server \"", "notification_email_host_description": "Хост на сървъра за електронна поща (например: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игорнорирайте сертификационни грешки", "notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index a0fd6ff437..ba33c9b156 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "NOTA: Això és irreversible!", "note_unlimited_quota": "Nota: Intruduïu 0 per a quota il·limitada", "notification_email_from_address": "Des de l'adreça", - "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", + "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", "notification_email_host_description": "Amfitrió del servidor de correu electrònic (p.ex. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora els errors de certificat", "notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index ec97fe01b2..e49d3700ee 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "UPOZORNĚNÍ: Toto nelze později změnit!", "note_unlimited_quota": "Upozornění: Pro neomezenou kvótu zadejte 0", "notification_email_from_address": "Adresa Od", - "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", "notification_email_host_description": "Adresa e-mailového serveru (např. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorovat chyby certifikátů", "notification_email_ignore_certificate_errors_description": "Ignorovat chyby ověření certifikátu TLS (nedoporučuje se)", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index eb9d99d074..ab1d57d48e 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "BEMÆRK: Dette kan ikke ændres senere!", "note_unlimited_quota": "Bemærk: Indsæt 0 for uendelig kvote", "notification_email_from_address": "Fra adressse", - "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", + "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", "notification_email_host_description": "Host af emailserver (fx smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorér certifikatfejl", "notification_email_ignore_certificate_errors_description": "Ignorér TLS-certifikatgodkendelsesfejl (ikke anbefalet)", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 77b420db00..d519352862 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "HINWEIS: Dies kann später nicht mehr geändert werden!", "note_unlimited_quota": "Hinweis: 0 eingeben für unlimitiertes Kontingent", "notification_email_from_address": "Von", - "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", "notification_email_host_description": "Host des E-Mail-Servers (z.B. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoriere Zertifikats-Fehler", "notification_email_ignore_certificate_errors_description": "TLS-Zertifikatsvalidierungsfehler ignorieren (nicht empfohlen)", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index e27cc54d52..f880dab347 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -150,7 +150,7 @@ "note_cannot_be_changed_later": "NOTE: This cannot be changed later!", "note_unlimited_quota": "Note: Enter 0 for unlimited quota", "notification_email_from_address": "From address", - "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", + "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignore certificate errors", "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index f0c89ffb46..31c613dcbd 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTA: No se puede cambiar posteriormente!", "note_unlimited_quota": "Nota: usa 0 para espacio sin límites", "notification_email_from_address": "Desde", - "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host del servidor de correo electrónico (por ejemplo: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar errores de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar los errores de validación del certificado TLS (no recomendado)", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 25cdba4cb4..49b60cd052 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -125,7 +125,7 @@ "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", "notification_email_from_address": "Saatja aadress", - "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", + "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoreeri sertifikaadi vigu", "notification_email_ignore_certificate_errors_description": "Ignoreeri TLS sertifikaadi valideerimise vigu (mittesoovituslik)", diff --git a/web/src/lib/i18n/fa.json b/web/src/lib/i18n/fa.json index 0eb6b7b014..6cd77c157f 100644 --- a/web/src/lib/i18n/fa.json +++ b/web/src/lib/i18n/fa.json @@ -144,7 +144,7 @@ "note_cannot_be_changed_later": "توجه: این را نمی توان بعداً تغییر داد!", "note_unlimited_quota": "توجه: برای سهمیه نامحدود، عدد 0 را وارد کنید", "notification_email_from_address": "آدرس فرستنده", - "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", + "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", "notification_email_host_description": "میزبان سرور ایمیل (مثلاً smtp.immich.app)", "notification_email_ignore_certificate_errors": "خطاهای گواهی را نادیده بگیر", "notification_email_ignore_certificate_errors_description": "خطاهای اعتبارسنجی گواهی TLS را نادیده بگیر (توصیه نمی‌شود)", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index da9a71379c..6d951b93f9 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "Huom: Tätä ei voi enää myöhemmin vaihtaa!", "note_unlimited_quota": "Huom: Määritä 0 rajoittamattomaksi kiintiöksi", "notification_email_from_address": "Lähettäjän osoite", - "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich Kuvapalvelin \"", + "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich Kuvapalvelin \"", "notification_email_host_description": "Sähköpostipalvelin (esim. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Älä huomioi sertifikaattivirheitä", "notification_email_ignore_certificate_errors_description": "Älä huomioi TLS sertifikaattien validointivirheitä (ei suositeltu)", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index aefa831897..e2b8362568 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "הערה: אי אפשר לשנות זאת מאוחר יותר!", "note_unlimited_quota": "הערה: הזן 0 עבור מכסת אחסון בלתי מוגבלת", "notification_email_from_address": "מכתובת", - "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", + "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", "notification_email_host_description": "מארח שרת הדוא\"ל (למשל smtp.immich.app)", "notification_email_ignore_certificate_errors": "התעלם משגיאות תעודה", "notification_email_ignore_certificate_errors_description": "התעלם משגיאות אימות תעודת TLS (לא מומלץ)", diff --git a/web/src/lib/i18n/hi.json b/web/src/lib/i18n/hi.json index 2f2aabfb7e..d84c612224 100644 --- a/web/src/lib/i18n/hi.json +++ b/web/src/lib/i18n/hi.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "नोट: इसे बाद में बदला नहीं जा सकता!", "note_unlimited_quota": "नोट: असीमित कोटा के लिए 0 दर्ज करें", "notification_email_from_address": "इस पते से", - "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", + "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", "notification_email_host_description": "ईमेल सर्वर का होस्ट (उदा. smtp.immitch.app)", "notification_email_ignore_certificate_errors": "प्रमाणपत्र त्रुटियों पर ध्यान न दें", "notification_email_ignore_certificate_errors_description": "टीएलएस प्रमाणपत्र सत्यापन त्रुटियों पर ध्यान न दें (अनुशंसित नहीं)", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 291abaa690..16d08bbfca 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -146,7 +146,7 @@ "note_cannot_be_changed_later": "NAPOMENA: Ovo se ne može promijeniti kasnije!", "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Od adrese", - "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", "notification_email_host_description": "Poslužitelja e-pošte (npr. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoriraj pogreške certifikata", "notification_email_ignore_certificate_errors_description": "Ignoriraj pogreške provjere valjanosti TLS certifikata (nije preporučeno)", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 753839c384..249b663a77 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -149,7 +149,7 @@ "note_cannot_be_changed_later": "FIGYELEM: ezt később nem lehet megváltoztatni!", "note_unlimited_quota": "Megjegyzés: 0 - korlátlan kvóta", "notification_email_from_address": "Feladó cím", - "notification_email_from_address_description": "Küldő email címe, például: \"Immich Fotószerver \"", + "notification_email_from_address_description": "Küldő email címe, például: \"Immich Fotószerver \"", "notification_email_host_description": "Email szerver kiszolgálója (pl. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Tanúsítvány hibák figyelmen kívül hagyása", "notification_email_ignore_certificate_errors_description": "TLS tanúsítvány érvényességi hibák figyelmen kívül hagyása (nem ajánlott)", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index ea5ad94b27..1321bd358b 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -150,7 +150,7 @@ "note_cannot_be_changed_later": "CATATAN: Ini tidak akan dapat diubah lagi!", "note_unlimited_quota": "Catatan: Masukkan 0 untuk kuota tidak terbatas", "notification_email_from_address": "Dari alamat", - "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", + "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", "notification_email_host_description": "Hos server surel (mis. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Abaikan eror sertifikat", "notification_email_ignore_certificate_errors_description": "Abaikan eror validasi sertifikat TLS (tidak disarankan)", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index 6782b8fbb9..cbe3651927 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTA: Non potrà essere modificato in futuro!", "note_unlimited_quota": "Nota: Inserisci 0 per una quota illimitata", "notification_email_from_address": "Indirizzo mittente", - "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", + "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", "notification_email_host_description": "Host del server email (es. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora errori di certificato", "notification_email_ignore_certificate_errors_description": "Ignora errori di validazione del certificato TLS (sconsigliato)", diff --git a/web/src/lib/i18n/ja.json b/web/src/lib/i18n/ja.json index 017d52fb30..53dbde9a9e 100644 --- a/web/src/lib/i18n/ja.json +++ b/web/src/lib/i18n/ja.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "注意: 後から変更できません!", "note_unlimited_quota": "注意: 無制限にする場合は0を入力してください", "notification_email_from_address": "送信メールアドレス", - "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", + "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", "notification_email_host_description": "送信メールサーバーを設定します(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "証明書エラーを無視", "notification_email_ignore_certificate_errors_description": "TLS証明書の検証エラーを無視します(非推奨)", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index cd3db13102..df46923b5e 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "주의: 추후 변경할 수 없습니다!", "note_unlimited_quota": "참고: 할당량을 설정하지 않으려면 0을 입력하세요.", "notification_email_from_address": "보낸 사람 이메일", - "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", + "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", "notification_email_host_description": "이메일 서버의 호스트 (예: smtp.immich.app)", "notification_email_ignore_certificate_errors": "인증서 오류 무시", "notification_email_ignore_certificate_errors_description": "TLS 인증서 유효성 검사 오류 무시 (권장되지 않음)", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index df56d27a23..1c0e2f5eef 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -146,7 +146,7 @@ "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", "note_unlimited_quota": "Merk: Skriv inn 0 for ubegrenset kvote", "notification_email_from_address": "Fra adresse", - "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", + "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", "notification_email_host_description": "Verten til e-posts serveren (f.eks. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer sertifikatfeil", "notification_email_ignore_certificate_errors_description": "Ignorer valideringsfeil for TLS-sertifikat (ikke anbefalt)", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index d6b3373152..786a1627fe 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "LET OP: Dit kan later niet meer worden gewijzigd!", "note_unlimited_quota": "Opmerking: voer 0 in voor onbeperkt", "notification_email_from_address": "Adres afzender", - "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", + "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", "notification_email_host_description": "Host van de e-mailserver (bijv. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Negeer certificaatfouten", "notification_email_ignore_certificate_errors_description": "Negeer TLS certificaat validatiefouten (niet aanbevolen)", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index ff06e41233..089a6550cd 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "UWAŻAJ: Nie można tego później zmienić!", "note_unlimited_quota": "Wpisz by wyłączyć limit", "notification_email_from_address": "Z adresu", - "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", + "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", "notification_email_host_description": "Host serwera e-mail (np. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoruj niepoprawny certyfikat", "notification_email_ignore_certificate_errors_description": "Ignoruj błąd walidacji certyfikatu TLS (nie zalecane)", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index 24c13ef03d..ebe1e85729 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -149,7 +149,7 @@ "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", diff --git a/web/src/lib/i18n/pt_BR.json b/web/src/lib/i18n/pt_BR.json index 1e0de69aed..05f7bee7b0 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/web/src/lib/i18n/pt_BR.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 534794b6d3..29acdd03ce 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTĂ: Nu se va mai putea modifica ulterior!", "note_unlimited_quota": "Notă: Introduceți 0 pentru cotă nelimitată", "notification_email_from_address": "De la adresa", - "notification_email_from_address_description": "Adresa expeditorului, spre exemplu: „Immich Photo Server ”", + "notification_email_from_address_description": "Adresa expeditorului, spre exemplu: „Immich Photo Server ”", "notification_email_host_description": "Adresa serverului de email (e.g. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ingnoră erorile de certificat", "notification_email_ignore_certificate_errors_description": "Ignoră erorile de validare a certificatului TLS (nerecomandat)", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 660f7bc84c..bd725a11cf 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "ПРИМЕЧАНИЕ: Это невозможно изменить позже!", "note_unlimited_quota": "Примечание: Введите 0 для неограниченной квоты или оставьте пустым", "notification_email_from_address": "Адрес отправителя", - "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", "notification_email_host_description": "Доменное имя почтового сервера (например, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игнорировать ошибки сертификата", "notification_email_ignore_certificate_errors_description": "Игнорировать ошибки проверки сертификата TLS (не рекомендуется)", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 6618aeab1d..7bcd1e3dd8 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "НАПОМЕНА: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Напомена: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Са адресе", - "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", + "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", "notification_email_host_description": "Хост сервера е-поште (нпр. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Занемарите грешке сертификата", "notification_email_ignore_certificate_errors_description": "Игноришите грешке у валидацији ТЛС сертификата (не препоручује се)", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index ea40525a81..beb2009b4d 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NAPOMENA: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Sa adrese", - "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", + "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", "notification_email_host_description": "Host servera e-pošte (npr. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Zanemarite greške sertifikata", "notification_email_ignore_certificate_errors_description": "Ignorišite greške u validaciji TLS sertifikata (ne preporučuje se)", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index cebb74377e..b00d521b20 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "OBS: Detta kan inte ändras i efterhand!", "note_unlimited_quota": "OBS: Skriv 0 för obegränsad kvota", "notification_email_from_address": "Från adress", - "notification_email_from_address_description": "Avsändarens epost, t.ex.: \"Immich Fotoserver \"", + "notification_email_from_address_description": "Avsändarens epost, t.ex.: \"Immich Fotoserver \"", "notification_email_host_description": "Värd för epostservern (t.ex. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorera certifikatfel", "notification_email_ignore_certificate_errors_description": "Ignorera valideringsfel för TLS-certifikat (rekommenderas ej)", diff --git a/web/src/lib/i18n/ta.json b/web/src/lib/i18n/ta.json index ec3f27124b..bb7e888b76 100644 --- a/web/src/lib/i18n/ta.json +++ b/web/src/lib/i18n/ta.json @@ -143,7 +143,7 @@ "note_cannot_be_changed_later": "குறிப்பு: இதை பின்னர் மாற்ற முடியாது!", "note_unlimited_quota": "குறிப்பு: வரம்பற்ற ஒதுக்கீட்டிற்கு 0 ஐ உள்ளிடவும்", "notification_email_from_address": "முகவரியிலிருந்து", - "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", + "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", "notification_email_host_description": "மின்னஞ்சல் சேவையகத்தின் ஹோஸ்ட் (எடுத்துக்காட்டாக: smtp.immich.app)", "notification_email_ignore_certificate_errors": "சான்றிதழ் பிழைகளை புறக்கணிக்கவும்", "notification_email_ignore_certificate_errors_description": "TLS சான்றிதழ் சரிபார்ப்பு பிழைகளை புறக்கணிக்கவும் (பரிந்துரைக்கப்படவில்லை)", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index 19496b4238..32336bfb4e 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "หมายเหตุ: ไม่สามารถเปลี่ยนภายหลังได้!", "note_unlimited_quota": "หมายเหตุ: ใส่เลข 0 สําหรับโควต้าไม่จํากัด", "notification_email_from_address": "จากที่อยู่", - "notification_email_from_address_description": "อีเมลผู้ส่ง อย่างเช่น \"Immich Photo Server \"", + "notification_email_from_address_description": "อีเมลผู้ส่ง อย่างเช่น \"Immich Photo Server \"", "notification_email_host_description": "ที่อยู่เซิร์ฟเวอร์อีเมล (เช่น smtp.immich.app)", "notification_email_ignore_certificate_errors": "ไม่สนใจข้อผิดพลาดเกี่ยวกับใบรับรอง", "notification_email_ignore_certificate_errors_description": "ไม่สนใจการยืนยันใบรับรอง TLS ผิดพลาด (ไม่แนะนำ)", diff --git a/web/src/lib/i18n/tr.json b/web/src/lib/i18n/tr.json index 4fefbf2f21..3a8a0db8af 100644 --- a/web/src/lib/i18n/tr.json +++ b/web/src/lib/i18n/tr.json @@ -151,7 +151,7 @@ "note_cannot_be_changed_later": "NOT: Bu daha sonra değiştirilemez!", "note_unlimited_quota": "NOT: Sınırsız kota için 0 yazın", "notification_email_from_address": "Şu adresten", - "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", + "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", "notification_email_host_description": "E-posta sunucusunun ana bilgisayarı (örneğin, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Sertifika hatalarını görmezden gel", "notification_email_ignore_certificate_errors_description": "TLS sertifika doğrulama ayarlarını görmezden gel (Önerilmez)", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 64411ef758..ce72fde8b4 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "ПРИМІТКА: Це не можна змінити пізніше!", "note_unlimited_quota": "Примітка: Введіть 0 для необмеженого обсягу квоти", "notification_email_from_address": "З адреси", - "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", "notification_email_host_description": "Хост поштового сервера (наприклад, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ігнорувати помилки сертифіката", "notification_email_ignore_certificate_errors_description": "Ігнорувати помилки перевірки сертифікатів TLS (не рекомендується)", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index c4f23ec273..e94eb7a464 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "LƯU Ý: Cài đặt này không thể thay đổi được sau khi lưu!", "note_unlimited_quota": "Lưu ý: Nhập 0 để hạn mức không giới hạn", "notification_email_from_address": "Địa chỉ email người gửi", - "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", + "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", "notification_email_host_description": "Địa chỉ máy chủ email (ví dụ: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Bỏ qua các lỗi chứng chỉ", "notification_email_ignore_certificate_errors_description": "Bỏ qua lỗi xác thực chứng chỉ TLS (không khuyến nghị)", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index 30e32f60c9..fb9a18a1f5 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "註:之後就無法更改嘍!", "note_unlimited_quota": "註:輸入 0 表示不限制配額", "notification_email_from_address": "寄件地址", - "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", + "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", "notification_email_host_description": "電子郵件伺服器主機(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略憑證錯誤", "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index b56c2d29eb..e879365f41 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", From edb085691a67c84997f4f34ca2dede7f82af2bb2 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 16 Sep 2024 18:19:57 +0200 Subject: [PATCH 002/599] chore(web): update translations (#12590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/es/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/ 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/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/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/ Translation: Immich/immich Co-authored-by: Bezruchenko Simon Co-authored-by: Boris Garmev Co-authored-by: David Abner Ciuhan Co-authored-by: Dean Cvjetanović Co-authored-by: Denis Pacquier Co-authored-by: Eero Jääskeläinen Co-authored-by: Javier Montón Co-authored-by: Junghyuk Kwon Co-authored-by: Michal Micech Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Owen Higgins Co-authored-by: Pat Oakly Co-authored-by: Poramate Homprakob Co-authored-by: Riccardo Co-authored-by: RoanV Co-authored-by: Roger Veciana Rovira Co-authored-by: Rémi Saurel Co-authored-by: Sam Smith Co-authored-by: Vladimir Petrov (Vlado) Co-authored-by: Xo Co-authored-by: aarhor Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: kiwinho Co-authored-by: pyccl Co-authored-by: pyorot Co-authored-by: waclaw66 --- web/src/lib/i18n/bg.json | 80 +- web/src/lib/i18n/ca.json | 73 +- web/src/lib/i18n/cs.json | 3 + web/src/lib/i18n/da.json | 14 + web/src/lib/i18n/de.json | 2 +- web/src/lib/i18n/es.json | 65 +- web/src/lib/i18n/fi.json | 6 + web/src/lib/i18n/fr.json | 52 +- web/src/lib/i18n/he.json | 14 +- web/src/lib/i18n/hr.json | 1136 ++++++++++++++++----------- web/src/lib/i18n/it.json | 3 + web/src/lib/i18n/ko.json | 8 +- web/src/lib/i18n/lv.json | 120 +-- web/src/lib/i18n/nb_NO.json | 3 + web/src/lib/i18n/nl.json | 2 + web/src/lib/i18n/ro.json | 304 ++++--- web/src/lib/i18n/ru.json | 5 +- web/src/lib/i18n/sk.json | 4 +- web/src/lib/i18n/sr_Cyrl.json | 3 + web/src/lib/i18n/sr_Latn.json | 3 + web/src/lib/i18n/th.json | 17 +- web/src/lib/i18n/uk.json | 4 + web/src/lib/i18n/vi.json | 3 + web/src/lib/i18n/zh_SIMPLIFIED.json | 66 +- 24 files changed, 1237 insertions(+), 753 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index ef739e7452..29ac04eda8 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -137,7 +137,7 @@ "map_settings_description": "Управление на настройките на картата", "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", "metadata_extraction_job": "Извличане на метаданни", - "metadata_extraction_job_description": "Извличане на метаданни от всеки ресурс, като GPS и резолюция", + "metadata_extraction_job_description": "Извличане на метаданни от всеки от ресурсите, като GPS локация, лица и резолюция на файловете", "metadata_faces_import_setting": "Включи импорт на лице", "metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове", "metadata_settings": "Опции за метаданни", @@ -176,7 +176,7 @@ "oauth_issuer_url": "URL на издателя", "oauth_mobile_redirect_uri": "URI за мобилно пренасочване", "oauth_mobile_redirect_uri_override": "URI пренасочване за мобилни устройства", - "oauth_mobile_redirect_uri_override_description": "Разреши когато 'app.immich:/' е невалиден пренасочвар адрес/URI.", + "oauth_mobile_redirect_uri_override_description": "Разреши когато доставчика за OAuth удостоверяване не позволява за мобилни URI идентификатори, като '{callback}'", "oauth_profile_signing_algorithm": "Алгоритъм за създаване на профили", "oauth_profile_signing_algorithm_description": "Алгоритъм излпозлван за вписване на потребителски профил.", "oauth_scope": "Област/обхват на приложение", @@ -244,7 +244,7 @@ "thumbnail_generation_job": "Генериране на миниатюри", "thumbnail_generation_job_description": "Генерирайте големи, малки и замъглени миниатюри за всеки актив, както и миниатюри за всеки човек", "transcoding_acceleration_api": "API за ускоряване", - "transcoding_acceleration_api_description": "API, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „best effort“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може или не може да работи в зависимост от вашия хардуер.", + "transcoding_acceleration_api_description": "API интерфейсът, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „възможно най-доброто“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може и да не работи в зависимост от вашия хардуер.", "transcoding_acceleration_nvenc": "NVENC (необходим NVIDIA GPU)", "transcoding_acceleration_qsv": "Quick Sync (необходим 7th поколение Intel CPU или по-ново)", "transcoding_acceleration_rkmpp": "RKMPP (само на Rockchip SOCs)", @@ -252,9 +252,9 @@ "transcoding_accepted_audio_codecs": "Допустими аудио кодеци", "transcoding_accepted_audio_codecs_description": "Изберете кои аудио кодеци не са нужни за разкодиране. Използва се само за определени правила за разкодиране.", "transcoding_accepted_containers": "Приети контейнери", - "transcoding_accepted_containers_description": "Изберете кои формати на контейнери не трябва да се пренасочват към MP4. Използва се само за определени правила за разкодиране.", + "transcoding_accepted_containers_description": "Изберете кои формати на контейнери не е нужно да бъдат преобразувани в MP4 формат. Използва се само за определени правила за разкодиране.", "transcoding_accepted_video_codecs": "Приети видео кодеци", - "transcoding_accepted_video_codecs_description": "Изберете кои видео кодеци не трябва да се разкодиране. Използва се само за определени правила за разкодиране.", + "transcoding_accepted_video_codecs_description": "Изберете кои видео кодеци не трябват за разкодиране. Използва се само за определени правила за разкодиране.", "transcoding_advanced_options_description": "Опции, които повечето потребители не трябва да променят", "transcoding_audio_codec": "Аудио кодек", "transcoding_audio_codec_description": "Opus е опцията с най-високо качество, но има по-ниска съвместимост със стари устройства или софтуер.", @@ -446,7 +446,7 @@ "copy_to_clipboard": "Копиране в клипборда", "country": "Държава", "cover": "", - "covers": "", + "covers": "Обложка", "create": "Създай", "create_album": "Създай албум", "create_library": "Създай библиотека", @@ -938,19 +938,22 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "search_people": "Търсете на хора", + "search_places": "Търсене на места", "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", + "search_tags": "Търсене на етикети...", + "search_timezone": "Търсене на часова зона...", + "search_type": "Тип на търсене", + "search_your_photos": "Търсете вашите снимки", "searching_locales": "", "second": "Секунда", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", + "see_all_people": "Вижте всички хора", + "select_album_cover": "Изберете обложка на албум", + "select_all": "Изберете всички", + "select_avatar_color": "Изберете цвят на аватара", + "select_face": "Изберете лице", "select_featured_photo": "", + "select_from_computer": "Изберете от компютъра", "select_keep_all": "", "select_library_owner": "Изберете собственик на библиотека", "select_new_face": "Изберете ново лице", @@ -998,28 +1001,40 @@ "show_metadata": "Покажи метаданни", "show_or_hide_info": "Покажи или скрий информацията", "show_password": "Покажи паролата", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", + "show_person_options": "Показване на опции за лица", + "show_progress_bar": "Показване на прогрес бара", + "show_search_options": "Показване на опциите за търсене", + "show_supporter_badge": "Значка поддръжник", + "show_supporter_badge_description": "Покажи значка поддръжник", "shuffle": "Разбъркване", - "sign_out": "", - "sign_up": "", + "sidebar": "Странична лента", + "sidebar_display_description": "Показване на връзка към изгледа в страничната лента", + "sign_out": "Отписване", + "sign_up": "Запиши се", "size": "Размер", - "skip_to_content": "", + "skip_to_content": "Премини към съдържанието", + "skip_to_folders": "Премини към папките", + "skip_to_tags": "Премини към етикетите", "slideshow": "Слайдшоу", - "slideshow_settings": "", - "sort_albums_by": "", + "slideshow_settings": "Настройки за слайдшоу", + "sort_albums_by": "Сортиране на албуми по...", + "sort_created": "Дата на създаване", + "sort_items": "Брой елементи", + "sort_modified": "Дата на промяна", + "sort_oldest": "Най-старата снимка", + "sort_recent": "Най-новата снимка", "sort_title": "Заглавие", "source": "Източник", "stack": "", - "stack_selected_photos": "", + "stack_duplicates": "Подреждане на дубликати", + "stack_selected_photos": "Подреждане на избрани снимки", "stacktrace": "", "start": "Старт", - "start_date": "", + "start_date": "Начална дата", "state": "", "status": "Статус", "stop_motion_photo": "", - "stop_photo_sharing": "Да спрете ли споделянето на вашите снимки?", + "stop_photo_sharing": "Да спра ли споделянето на вашите снимки?", "stop_photo_sharing_description": "{partner} вече няма достъп до вашите снимки.", "stop_sharing_photos_with_user": "Прекратете споделянето на снимки с този потребител", "storage": "Пространство на хранилището", @@ -1030,6 +1045,12 @@ "sunrise_on_the_beach": "Изгрев на плажа", "swap_merge_direction": "Размяна посоката на сливане", "sync": "Синхронизиране", + "tag": "Таг", + "tag_created": "Създаден етикет: {tag}", + "tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове", + "tag_not_found_question": "Не можете да намерите етикет? Създайте такъв тук", + "tag_updated": "Актуализиран етикет: {tag}", + "tags": "Етикет", "template": "Шаблон", "theme": "Тема", "theme_selection": "Избор на тема", @@ -1048,7 +1069,7 @@ "total_usage": "Общо използвано", "trash": "кошче", "trash_all": "Изхвърли всички", - "trash_count": "", + "trash_count": "Кошче {count, number}", "trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.", "trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# day} other {# days}}.", "type": "Тип", @@ -1062,6 +1083,7 @@ "unlink_oauth": "", "unlinked_oauth_account": "", "unnamed_album": "Албум без име", + "unnamed_album_delete_confirmation": "Сигурни ли сте, че искате да изтриете този албум?", "unnamed_share": "Споделяне без име", "unsaved_change": "Незапазена промяна", "unselect_all": "Деселектирайте всички", @@ -1085,7 +1107,7 @@ "user_purchase_settings": "Покупка", "user_purchase_settings_description": "Управлявай покупката си", "user_role_set": "Задай {user} като {role}", - "user_usage_detail": "", + "user_usage_detail": "Подробности за използването на потребителя", "username": "Потребителско име", "users": "Потребители", "utilities": "Инструменти", @@ -1103,9 +1125,11 @@ "view_album": "Разгледай албума", "view_all": "Преглед на всички", "view_all_users": "Преглед на всички потребители", + "view_in_timeline": "Покажи във времева линия", "view_links": "Преглед на връзките", "view_next_asset": "Преглед на следващия файл", "view_previous_asset": "Преглед на предишния файл", + "view_stack": "Покажи в стек", "viewer": "", "waiting": "в изчакване", "warning": "Внимание", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index ba33c9b156..e9c695f79a 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -129,16 +129,21 @@ "map_enable_description": "Habilita característiques del mapa", "map_gps_settings": "Configuració de mapa i GPS", "map_gps_settings_description": "Gestiona la configuració de mapa i GPS (Geocodificació inversa)", + "map_implications": "La funció mapa depèn del servei extern de tesel·les (tiles.immich.cloud)", "map_light_style": "Tema clar", "map_manage_reverse_geocoding_settings": "Gestiona els paràmetres de geocodificació inversa", "map_reverse_geocoding": "Geocodificació inversa", "map_reverse_geocoding_enable_description": "Habilita la geocodificació inversa", "map_reverse_geocoding_settings": "Configuració de Geocodificació Inversa", - "map_settings": "Configuració del mapa i GPS", + "map_settings": "Mapa", "map_settings_description": "Gestiona la configuració del mapa", "map_style_description": "URL a un tema del mapa style.json", "metadata_extraction_job": "Extreure metadades", "metadata_extraction_job_description": "Extreu la informació de metadades de cada element, com per exemple el GPS i la resolució", + "metadata_faces_import_setting": "Activar la importació de cares", + "metadata_faces_import_setting_description": "Importar cares des de les metadades EXIF de les imatges i arxius auxiliars", + "metadata_settings": "Configuració de les metadades", + "metadata_settings_description": "Administrar la configuració de les metadades", "migration_job": "Migració", "migration_job_description": "Migra les miniatures d'elements i cares cap a la nova estructura de carpetes", "no_paths_added": "Cap camí afegit", @@ -173,7 +178,7 @@ "oauth_issuer_url": "URL de l'emissor", "oauth_mobile_redirect_uri": "URI de redirecció mòbil", "oauth_mobile_redirect_uri_override": "Sobreescriu l'URI de redirecció mòbil", - "oauth_mobile_redirect_uri_override_description": "Habilita quan 'app.immich:/' és una URI de redirecció invàlida.", + "oauth_mobile_redirect_uri_override_description": "Habilita quan el proveïdor d'OAuth no permet una URI mòbil, com ara '{callback}'", "oauth_profile_signing_algorithm": "Algoritme de signatura del perfil", "oauth_profile_signing_algorithm_description": "Algoritme utilitzat per signar el perfil d’usuari.", "oauth_scope": "Abast", @@ -278,7 +283,7 @@ "transcoding_preferred_hardware_device": "Dispositiu de maquinari preferit", "transcoding_preferred_hardware_device_description": "S'aplica només a VAAPI i QSV. Estableix el node dri utilitzat per a la transcodificació de maquinari.", "transcoding_preset_preset": "Preestablert (-preset)", - "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a \"més ràpides\".", + "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a 'més ràpides'.", "transcoding_reference_frames": "Fotogrames de referència", "transcoding_reference_frames_description": "El nombre de fotogrames a fer referència en comprimir un fotograma determinat. Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. 0 estableix aquest valor automàticament.", "transcoding_required_description": "Només vídeos que no tenen un format acceptat", @@ -320,7 +325,8 @@ "user_settings": "Configuració d'usuaris", "user_settings_description": "Gestiona la configuració dels usuaris", "user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.", - "version_check_enabled_description": "Activa sol·licituds periòdiques a GitHub per comprovar si hi ha versions noves", + "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ó", "version_check_settings_description": "Activa/desactiva la notificació de nova versió", "video_conversion_job": "Transcodificació de vídeos", @@ -336,7 +342,8 @@ "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", - "album_delete_confirmation": "N'esteu segur que voleu suprimir l'àlbum {album}?\nSi aquest àlbum és compartit, altres usuaris no hi podran accedir més.", + "album_delete_confirmation": "Esteu segur que voleu suprimir l'àlbum {album}?", + "album_delete_confirmation_description": "Si aquest àlbum es comparteix, els altres usuaris ja no podran accedir-hi.", "album_info_updated": "Informació de l'àlbum actualitzada", "album_leave": "Sortir de l'àlbum?", "album_leave_confirmation": "N'esteu segur que voleu sortir de {album}?", @@ -360,6 +367,7 @@ "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", + "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", @@ -383,6 +391,7 @@ "asset_offline": "Element fora de línia", "asset_offline_description": "Aquest element està fora de línia. L'Immich no pot accedir a la seva ubicació. Si us plau, assegureu-vos que l'actiu està disponible i després torneu la llibreria.", "asset_skipped": "Saltat", + "asset_skipped_in_trash": "A la paperera", "asset_uploaded": "Carregat", "asset_uploading": "S'està carregant...", "assets": "Elements", @@ -440,9 +449,11 @@ "clear_all_recent_searches": "Esborra totes les cerques recents", "clear_message": "Neteja el missatge", "clear_value": "Neteja el valor", + "clockwise": "En sentit horari", "close": "Tanca", "collapse": "Tanca", "collapse_all": "Redueix-ho tot", + "color": "Color", "color_theme": "Tema de color", "comment_deleted": "Comentari esborrat", "comment_options": "Opcions de comentari", @@ -476,6 +487,8 @@ "create_new_person": "Crea una nova persona", "create_new_person_hint": "Assigna els elements seleccionats a una persona nova", "create_new_user": "Crea un usuari nou", + "create_tag": "Crear etiqueta", + "create_tag_description": "Crear una nova etiqueta. Per les etiquetes aniuades, escriu la ruta comperta de l'etiqueta, incloses les barres diagonals.", "create_user": "Crea un usuari", "created": "Creat", "current_device": "Dispositiu actual", @@ -499,6 +512,8 @@ "delete_library": "Suprimeix la llibreria", "delete_link": "Esborra l'enllaç", "delete_shared_link": "Odstranit sdílený odkaz", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Estàs segur que vols eliminar l'etiqueta {tagName}?", "delete_user": "Suprimeix l'usuari", "deleted_shared_link": "Suprimeix l'enllaç compartit", "description": "Descripció", @@ -516,6 +531,8 @@ "do_not_show_again": "No tornis a mostrar aquest missatge", "done": "Fet", "download": "Descarregar", + "download_include_embedded_motion_videos": "Vídeos incrustats", + "download_include_embedded_motion_videos_description": "Incloure vídeos incrustats en fotografies en moviment com un arxiu separat", "download_settings": "Descarregar", "download_settings_description": "Gestioneu la configuració relacionada amb la descàrrega de recursos", "downloading": "Baixant", @@ -545,10 +562,15 @@ "edit_location": "Edita ubicació", "edit_name": "Edita el nom", "edit_people": "Edita la gent", + "edit_tag": "Editar etiqueta", "edit_title": "Edita títol", "edit_user": "Edita l'usuari", "edited": "Editat", "editor": "Editor", + "editor_close_without_save_prompt": "No es desaran els canvis", + "editor_close_without_save_title": "Tancar l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Relació d'aspecte", + "editor_crop_tool_h2_rotation": "Rotació", "email": "Correu electrònic", "empty": "", "empty_album": "", @@ -638,6 +660,7 @@ "unable_to_get_comments_number": "No es pot obtenir el nombre de comentaris", "unable_to_get_shared_link": "No s'ha pogut obtenir l'enllaç compartit", "unable_to_hide_person": "No es pot amagar la persona", + "unable_to_link_motion_video": "No es pot enllaçar el vídeo en moviment", "unable_to_link_oauth_account": "No es pot enllaçar el compte OAuth", "unable_to_load_album": "No es pot carregar l'àlbum", "unable_to_load_asset_activity": "No es pot carregar l'activitat dels recursos", @@ -678,6 +701,7 @@ "unable_to_submit_job": "No es pot enviar la tasca", "unable_to_trash_asset": "No es pot eliminar el recurs a la paperera", "unable_to_unlink_account": "No es pot desenllaçar el compte", + "unable_to_unlink_motion_video": "No es pot desvincular el vídeo en moviment", "unable_to_update_album_cover": "No es pot actualitzar la portada de l'àlbum", "unable_to_update_album_info": "No es pot actualitzar la informació de l'àlbum", "unable_to_update_library": "No es pot actualitzar la biblioteca", @@ -698,6 +722,7 @@ "expired": "Caducat", "expires_date": "Caduca el {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exporta", "export_as_json": "Exportar com a JSON", "extension": "Extensió", @@ -711,6 +736,8 @@ "feature": "", "feature_photo_updated": "Foto destacada actualitzada", "featurecollection": "", + "features": "Característiques", + "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ó", "filename": "Nom del fitxer", @@ -719,6 +746,8 @@ "filter_people": "Filtra persones", "find_them_fast": "Trobeu-los ràpidament pel nom amb la cerca", "fix_incorrect_match": "Corregiu la coincidència incorrecta", + "folders": "Carpetes", + "folders_feature_description": "Explorar la vista de carpetes per les fotos i vídeos del sistema d'arxius", "force_re-scan_library_files": "Força a tornar a escanejar tots els fitxers de la biblioteca", "forward": "Endavant", "general": "General", @@ -803,6 +832,7 @@ "license_trial_info_3": "{accountAge, plural, one {# dia} other {# dies}}", "light": "Llum", "like_deleted": "M'agrada suprimit", + "link_motion_video": "Enllaçar vídeo en moviment", "link_options": "Opcions d'enllaç", "link_to_oauth": "Enllaç a OAuth", "linked_oauth_account": "Compte OAuth enllaçat", @@ -896,12 +926,14 @@ "ok": "D'acord", "oldest_first": "El més vell primer", "onboarding": "Onboarding", + "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", "onboarding_welcome_user": "Benvingut, {user}", "online": "En línia", "only_favorites": "Només preferits", "only_refreshes_modified_files": "Només actualitza els fitxers modificats", + "open_in_map_view": "Obrir a la vista del mapa", "open_in_openstreetmap": "Obre a OpenStreetMap", "open_the_search_filters": "Obriu els filtres de cerca", "options": "Opcions", @@ -936,6 +968,7 @@ "pending": "Pendent", "people": "Persones", "people_edits_count": "{count, plural, one {# persona editada} other {# persones editades}}", + "people_feature_description": "Explorar fotos i vídeos agrupades per persona", "people_sidebar_description": "Mostrar un enllaç a Persones a la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Avís d'eliminació permanent", @@ -967,6 +1000,7 @@ "previous_memory": "Memòria anterior", "previous_or_next_photo": "Foto anterior o següent", "primary": "Primària", + "privacy": "Privacitat", "profile_image_of_user": "Imatge de perfil de {user}", "profile_picture_set": "Imatge de perfil configurada.", "public_album": "Àlbum públic", @@ -1004,6 +1038,10 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clau de producte del servidor la gestiona l'administrador", "range": "", + "rating": "Valoració", + "rating_clear": "Esborrar valoració", + "rating_count": "{count, plural, one {# estrella} other {# estrelles}}", + "rating_description": "Mostrar la valoració EXIF al panell d'informació", "raw": "", "reaction_options": "Opcions de reacció", "read_changelog": "Llegeix el registre de canvis", @@ -1036,6 +1074,7 @@ "removed_from_archive": "Eliminat de l'arxiu", "removed_from_favorites": "Eliminat dels preferits", "removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits", + "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# actiu} other {# actius}}", "rename": "Canviar nom", "repair": "Reparació", "repair_no_results_message": "Els fitxers sense seguiment i que falten es mostraran aquí", @@ -1082,9 +1121,11 @@ "search_for_existing_person": "Busca una persona existent", "search_no_people": "Cap persona", "search_no_people_named": "Cap persona anomenada \"{name}\"", + "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", "search_state": "Buscar per regió...", + "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", "search_type": "Buscar per tipus", "search_your_photos": "Cerca les teves fotos", @@ -1126,6 +1167,7 @@ "shared_by_user": "Compartit per {user}", "shared_by_you": "Compartit per tu", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opcions d'enllaços compartits", "shared_links": "Enllaços compartits", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}", "shared_with_partner": "Compartit amb {partner}", @@ -1134,6 +1176,7 @@ "sharing_sidebar_description": "Mostra un enllaç a Compartit a la barra lateral", "shift_to_permanent_delete": "premeu ⇧ per suprimir el recurs permanentment", "show_album_options": "Mostra les opcions d'àlbum", + "show_albums": "Mostrar àlbums", "show_all_people": "Veure totes les persones", "show_and_hide_people": "Mostra i amaga persones", "show_file_location": "Mostra l'ubicació del fitxer", @@ -1151,10 +1194,14 @@ "show_supporter_badge": "Insígnia de contribuent", "show_supporter_badge_description": "Mostra una insígnia de contributor", "shuffle": "Mescla", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostra un enllaç a la vista a la barra lateral", "sign_out": "Tanca sessió", "sign_up": "Registrar-se", "size": "Mida", "skip_to_content": "Salta al contingut", + "skip_to_folders": "Anar a carpetes", + "skip_to_tags": "Anar a etiquetes", "slideshow": "Diapositives", "slideshow_settings": "Configuració de diapositives", "sort_albums_by": "Ordena àlbums per...", @@ -1166,6 +1213,8 @@ "sort_title": "Títol", "source": "Font", "stack": "Apila", + "stack_duplicates": "Aplicar duplicats", + "stack_select_one_photo": "Selecciona una imatge principal per la pila", "stack_selected_photos": "Apila les fotos seleccionades", "stacked_assets_count": "Apilats {count, plural, one {# element} other {# elements}}", "stacktrace": "Traça de pila", @@ -1185,6 +1234,14 @@ "sunrise_on_the_beach": "Albada a la platja", "swap_merge_direction": "Canvia la direcció d'unió", "sync": "Sincronitza", + "tag": "Etiqueta", + "tag_assets": "Etiquetar actius", + "tag_created": "Etiqueta creada: {tag}", + "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", + "tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una aquí", + "tag_updated": "Etiqueta actualizada: {tag}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", "theme_selection": "Selecció de tema", @@ -1196,9 +1253,10 @@ "to_change_password": "Canviar la contrasenya", "to_favorite": "Prefereix", "to_login": "Iniciar sessió", + "to_parent": "Anar als pares", "to_trash": "Paperera", "toggle_settings": "Canvia configuració", - "toggle_theme": "Canvia tema", + "toggle_theme": "Alternar tema", "toggle_visibility": "Canvia visibilitat", "total_usage": "Ús total", "trash": "Paperera", @@ -1217,9 +1275,11 @@ "unknown_album": "Àlbum desconegut", "unknown_year": "Any desconegut", "unlimited": "Il·limitat", + "unlink_motion_video": "Desvincular vídeo en moviment", "unlink_oauth": "Desvincula OAuth", "unlinked_oauth_account": "Compte Oauth desvinculat", "unnamed_album": "Àlbum sense nom", + "unnamed_album_delete_confirmation": "Segur que voleu esborrar aquest àlbum?", "unnamed_share": "Compartit sense nom", "unsaved_change": "Canvi no desat", "unselect_all": "Deselecciona-ho tot", @@ -1267,6 +1327,7 @@ "view_album": "Veure l'àlbum", "view_all": "Veure tot", "view_all_users": "Mostra tot els usuaris", + "view_in_timeline": "Mostrar a la línia de temps", "view_links": "Mostra enllaços", "view_next_asset": "Mostra el següent element", "view_previous_asset": "Mostra l'element anterior", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index e49d3700ee..c2d7bce0e5 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Nelze načíst počet komentářů", "unable_to_get_shared_link": "Nepodařilo se získat sdílený odkaz", "unable_to_hide_person": "Nelze skrýt osobu", + "unable_to_link_motion_video": "Nelze připojit pohyblivé video", "unable_to_link_oauth_account": "Nelze propojit OAuth účet", "unable_to_load_album": "Nelze načíst album", "unable_to_load_asset_activity": "Nelze načíst aktivitu položky", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Nelze odeslat úlohu", "unable_to_trash_asset": "Nelze vyhodit položku do koše", "unable_to_unlink_account": "Nelze zrušit propojení účtu", + "unable_to_unlink_motion_video": "Nelze odpojit pohyblivé video", "unable_to_update_album_cover": "Nelze aktualizovat obal alba", "unable_to_update_album_info": "Nelze aktualizovat informace o albu", "unable_to_update_library": "Nelze aktualizovat knihovnu", @@ -1292,6 +1294,7 @@ "unknown_album": "Neznámé album", "unknown_year": "Neznámý rok", "unlimited": "Neomezeně", + "unlink_motion_video": "Odpojit pohyblivé video", "unlink_oauth": "Zrušit OAuth propojení", "unlinked_oauth_account": "OAuth účet odpojen", "unnamed_album": "Nepojmenované album", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index ab1d57d48e..1e2c9a2b4a 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -140,6 +140,10 @@ "map_style_description": "URL til en style.json for et korttema", "metadata_extraction_job": "Udtræk metadata", "metadata_extraction_job_description": "Udtræk metadataoplysninger fra hvert Billede/Video, såsom GPS og opløsning", + "metadata_faces_import_setting": "Aktivér for at importere ansigter", + "metadata_faces_import_setting_description": "Importerer ansigter fra billed EXIF-data og forbandt filer", + "metadata_settings": "Metadatainstillinger", + "metadata_settings_description": "Håndtér metadataindstillinger", "migration_job": "Migrering", "migration_job_description": "Migrér miniaturebilleder for aktiver og ansigter til den seneste mappestruktur", "no_paths_added": "Ingen stier tilføjet", @@ -347,15 +351,25 @@ "album_options": "Albumindstillinger", "album_remove_user": "Fjern bruger?", "album_remove_user_confirmation": "Er du sikker på at du vil fjerne {user}?", + "album_share_no_users": "Det ser ud til at du har delt denne album med alle brugere, eller du har ikke nogen brugere til at dele med.", "album_updated": "Album opdateret", "album_updated_setting_description": "Modtag en emailnotifikation når et delt album får nye mediefiler", + "album_user_left": "Forlod {album}", + "album_user_removed": "Fjernede {user}", "albums": "Albummer", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albummer}}", "all": "Alt", + "all_albums": "Alle albummer", "all_people": "Alle personer", + "all_videos": "Alle videoer", "allow_dark_mode": "Tillad mørk tilstand", "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", + "anti_clockwise": "Mod uret", "api_key": "API-nøgle", + "api_key_description": "Denne værdi vises kun én gang. Venligst kopiér den før du lukker vinduet.", + "api_key_empty": "Din API-nøgle-navn burde ikke være tom", "api_keys": "API-nøgler", "app_settings": "Appindstillinger", "appears_in": "Optræder i", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index d519352862..352006ef6e 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -1171,7 +1171,7 @@ "server_stats": "Server-Statistiken", "server_version": "Server-Version", "set": "Speichern", - "set_as_album_cover": "Als Albumcover gesetzt", + "set_as_album_cover": "Als Albumcover festlegen", "set_as_profile_picture": "Als Profilbild festlegen", "set_date_of_birth": "Geburtsdatum festlegen", "set_profile_picture": "Profilbild einstellen", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 31c613dcbd..0136319192 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -312,7 +312,7 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", - "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# day} other {# days}}.", + "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", "user_delete_immediately": "La cuenta {user} y los archivos se pondrán en cola para su eliminación permanente inmediatamente.", @@ -336,8 +336,8 @@ "admin_password": "Contraseña del Administrador", "administration": "Administración", "advanced": "Avanzada", - "age_months": "Tiempo {months, plural, one {# month} other {# months}}", - "age_year_months": "1 año, {months, plural, one {# month} other {# months}}", + "age_months": "Tiempo {months, plural, one {# mes} other {# meses}}", + "age_year_months": "1 año, {months, plural, one {# mes} other {# meses}}", "age_years": "Edad {years, plural, one {# año} other {# años}}", "album_added": "Álbum añadido", "album_added_notification_setting_description": "Reciba una notificación por correo electrónico cuando lo agreguen a un álbum compartido", @@ -400,12 +400,12 @@ "assets_added_to_name_count": "Añadido {count, plural, one {# asset} other {# assets}} a {hasName, select, true {{name}} other {new album}}", "assets_count": "{count, plural, one {# activo} other {# activos}}", "assets_moved_to_trash": "Se movió {count, plural, one {# activo} other {# activos}} a la papelera", - "assets_moved_to_trash_count": "Movido {count, plural, one {# asset} other {# assets}} a la papelera", - "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", - "assets_removed_count": "Eliminado {count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "Movido {count, plural, one {# elemento} other {# elementos}} a la papelera", + "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", + "assets_removed_count": "Eliminado {count, plural, one {# elemento} other {# elementos}}", "assets_restore_confirmation": "¿Está seguro de que desea restaurar todos sus archivos eliminados? ¡No puedes deshacer esta acción!", - "assets_restored_count": "Restaurado {count, plural, one {# asset} other {# assets}}", - "assets_trashed_count": "Borrado {count, plural, one {# asset} other {# assets}}", + "assets_restored_count": "Restaurado {count, plural, one {# elemento} other {# elementos}}", + "assets_trashed_count": "Borrado {count, plural, one {# elemento} other {# elementos}}", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Atrás", @@ -416,7 +416,7 @@ "blurred_background": "Fondo borroso", "build": "Compilación", "build_image": "Construir Imagen", - "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# duplicate asset} other {# duplicate assets}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", + "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", "bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.", "bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.", "buy": "Comprar Immich", @@ -589,7 +589,7 @@ "cant_apply_changes": "No se pueden aplicar los cambios", "cant_change_activity": "No se puede realizar la actividad {enabled, select, true {disable} other {enable}}", "cant_change_asset_favorite": "No se puede cambiar favorito para este archivo", - "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# asset} other {# assets}}", + "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# elemento} other {# elementos}}", "cant_get_faces": "No se encuentran caras", "cant_get_number_of_comments": "No se puede obtener la cantidad de comentarios", "cant_search_people": "No se puede buscar a personas", @@ -616,7 +616,7 @@ "failed_to_unstack_assets": "Error al desagrupar los archivos", "import_path_already_exists": "Esta ruta de importación ya existe.", "incorrect_email_or_password": "Contraseña o email incorrecto", - "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpetas} other {# carpetas}}", + "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpeta} other {# carpetas}}", "profile_picture_transparent_pixels": "Las imágenes de perfil no pueden tener píxeles transparentes. Por favor amplíe y/o mueva la imagen.", "quota_higher_than_disk_size": "Se ha establecido una cuota superior al tamaño del disco", "repair_unable_to_check_items": "No se puede verificar {count, select, one {elemento} other {elementos}}", @@ -634,7 +634,7 @@ "unable_to_change_favorite": "Imposible cambiar el archivo favorito", "unable_to_change_location": "No se puede cambiar de ubicación", "unable_to_change_password": "No se puede cambiar la contraseña", - "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# person} other {# people}}", + "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# persona} other {# personas}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "No se puede completar el inicio de sesión de OAuth", @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "No se puede obtener el número de comentarios", "unable_to_get_shared_link": "Error al obtener el enlace compartido", "unable_to_hide_person": "No se puede ocultar a la persona", + "unable_to_link_motion_video": "No se puede enlazar el vídeo en movimiento", "unable_to_link_oauth_account": "No se puede vincular la cuenta OAuth", "unable_to_load_album": "No se puede cargar el álbum", "unable_to_load_asset_activity": "No se puede cargar la actividad de los archivos", @@ -701,6 +702,7 @@ "unable_to_submit_job": "No se puede enviar el trabajo", "unable_to_trash_asset": "No se puede eliminar el archivo", "unable_to_unlink_account": "No se puede desvincular la cuenta", + "unable_to_unlink_motion_video": "No se puede desvincular el vídeo en movimiento", "unable_to_update_album_cover": "No se puede actualizar la portada del álbum", "unable_to_update_album_info": "No se puede actualizar la información del álbum", "unable_to_update_library": "No se puede actualizar la biblioteca", @@ -846,6 +848,7 @@ "license_trial_info_4": "Por favor, considera la compra de una licencia para apoyar el desarrollo continuo del servicio", "light": "Claro", "like_deleted": "Me gusta eliminado", + "link_motion_video": "Enlazar vídeo en movimiento", "link_options": "Opciones de enlace", "link_to_oauth": "Enlace a OAuth", "linked_oauth_account": "Cuenta OAuth vinculada", @@ -888,7 +891,7 @@ "merge_people_limit": "Solo puedes fusionar hasta 5 caras a la vez", "merge_people_prompt": "¿Quieres fusionar a estas personas? Esta acción es irreversible.", "merge_people_successfully": "Personas fusionadas correctamente", - "merged_people_count": "Fusionar {count, plural, one {# person} other {# people}}", + "merged_people_count": "Fusionada {count, plural, one {# persona} other {# personas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Perdido", @@ -969,9 +972,9 @@ "password_required": "Contraseña requerida", "password_reset_success": "Restablecimiento de contraseña exitoso", "past_durations": { - "days": "Pasados {days, plural, one {day} other {# days}}", - "hours": "Pasadas {hours, plural, one {hour} other {# hours}}", - "years": "Pasado(s) {years, plural, one {year} other {# years}}" + "days": "Pasados {days, plural, one {día} other {# días}}", + "hours": "Pasadas {hours, plural, one {hora} other {# horas}}", + "years": "Pasado(s) {years, plural, one {año} other {# años}}" }, "path": "Ruta", "pattern": "Patrón", @@ -980,18 +983,18 @@ "paused": "Detenido", "pending": "Pendiente", "people": "Personas", - "people_edits_count": "Editado {count, plural, one {# person} other {# people}}", + "people_edits_count": "Editada {count, plural, one {# persona} other {# personas}}", "people_feature_description": "Explorar fotos y vídeos agrupados por personas", "people_sidebar_description": "Mostrar un enlace a Personas en la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Advertencia de eliminación permanente", "permanent_deletion_warning_setting_description": "Mostrar una advertencia al eliminar archivos permanentemente", "permanently_delete": "Borrar permanentemente", - "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {asset} other {assets}}", - "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {¿este activo?} other {¿estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {elemento} other {elementos}}", + "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {este activo?} other {estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", "permanently_deleted_asset": "Archivo eliminado permanentemente", "permanently_deleted_assets": "Eliminado permanentemente {count, plural, one {# activo} other {# activos}}", - "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", + "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", "person": "Persona", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", "photo_shared_all_users": "Parece que compartiste tus fotos con todos los usuarios o no tienes ningún usuario con quien compartirlas.", @@ -1060,8 +1063,8 @@ "reaction_options": "Opciones de reacción", "read_changelog": "Leer registro de cambios", "reassign": "Reasignar", - "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", - "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# asset} other {# assets}} a un nuevo usuario", + "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a {name, select, null {una persona existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a un nuevo usuario", "reassing_hint": "Asignar archivos seleccionados a una persona existente", "recent": "Reciente", "recent_searches": "Búsquedas recientes", @@ -1075,8 +1078,8 @@ "refreshing_metadata": "Recargando metadatos", "regenerating_thumbnails": "Recargando miniaturas", "remove": "Eliminar", - "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del álbum?", - "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del enlace compartido?", + "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del álbum?", + "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del enlace compartido?", "remove_assets_title": "¿Eliminar activos?", "remove_custom_date_range": "Eliminar intervalo de fechas personalizado", "remove_from_album": "Eliminar del álbum", @@ -1087,7 +1090,7 @@ "removed_api_key": "Clave API eliminada: {name}", "removed_from_archive": "Eliminado del archivo", "removed_from_favorites": "Eliminado de favoritos", - "removed_from_favorites_count": "{count, plural, other {Removed #}} de favoritos", + "removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos", "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}", "rename": "Renombrar", "repair": "Reparar", @@ -1135,6 +1138,7 @@ "search_for_existing_person": "Buscar persona existente", "search_no_people": "Ninguna persona", "search_no_people_named": "Ninguna persona llamada \"{name}\"", + "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", "search_state": "Buscar región/estado...", @@ -1229,7 +1233,7 @@ "stack_duplicates": "Apilar duplicados", "stack_select_one_photo": "Selecciona una imagen principal para la pila", "stack_selected_photos": "Apilar fotos seleccionadas", - "stacked_assets_count": "Apilados {count, plural, one {# asset} other {# assets}}", + "stacked_assets_count": "Apilado(s) {count, plural, one {# activo} other {# activos}}", "stacktrace": "Stacktrace", "start": "Inicio", "start_date": "Fecha de inicio", @@ -1289,6 +1293,7 @@ "unknown_album": "Álbum desconocido", "unknown_year": "Año desconocido", "unlimited": "Ilimitado", + "unlink_motion_video": "Desvincular vídeo en movimiento", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Cuenta OAuth desconectada", "unnamed_album": "Album sin nombre", @@ -1298,14 +1303,14 @@ "unselect_all": "Limpiar selección", "unselect_all_duplicates": "Deseleccionar todos los duplicados", "unstack": "Desapilar", - "unstacked_assets_count": "Sin apilar {count, plural, one {# asset} other {# assets}}", + "unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}", "untracked_files": "Archivos no monitorizados", "untracked_files_decription": "Estos archivos no están siendo monitorizados por la aplicación. Es posible que sean resultado de errores al moverlos, cargas interrumpidas o por un fallo de la aplicación", "up_next": "A continuación", "updated_password": "Contraseña actualizada", "upload": "Subir", "upload_concurrency": "Cargas simultáneas", - "upload_errors": "Carga completada con {count, plural, one {# error} other {# errors}}, actualice la página para ver los nuevos recursos de carga.", + "upload_errors": "Carga completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de carga.", "upload_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}", "upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_status_duplicates": "Duplicados", @@ -1347,14 +1352,14 @@ "view_previous_asset": "Mostrar elemento anterior", "view_stack": "Ver Pila", "viewer": "Visualizador", - "visibility_changed": "Visibilidad cambiada para {count, plural, one {# person} other {# people}}", + "visibility_changed": "Visibilidad cambiada para {count, plural, one {# persona} other {# personas}}", "waiting": "Esperando", "warning": "Advertencia", "week": "Semana", "welcome": "Bienvenido", "welcome_to_immich": "Bienvenido a immich", "year": "Año", - "years_ago": "Hace {years, plural, one {# year} other {# years}}", + "years_ago": "Hace {years, plural, one {# año} other {# años}}", "yes": "Sí", "you_dont_have_any_shared_links": "No tienes ningún enlace compartido", "zoom_image": "Acercar Imagen" diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index 6d951b93f9..15a3dc0a26 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -140,6 +140,10 @@ "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF -tiedoista ja kylkiäistiedostoista", + "metadata_settings": "Metatietoasetukset", + "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", "migration_job_description": "Migroi aineiston pikkukuvat ja kasvot uusimpaan kansiorakenteeseen", "no_paths_added": "Polkuja ei asetettu", @@ -963,6 +967,7 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_online": "Palvelin on linjalla", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", "set": "Aseta", @@ -1113,6 +1118,7 @@ "view_album": "Näytä albumi", "view_all": "Näytä kaikki", "view_all_users": "Näytä kaikki käyttäjät", + "view_in_timeline": "Näytä aikajanalla", "view_links": "Näytä linkit", "view_next_asset": "Näytä seuraava", "view_previous_asset": "Näytä edellinen", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9edcb1fdd2..9628573b0d 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -148,7 +148,7 @@ "migration_job_description": "Migration des miniatures pour les médias et les visages vers la dernière structure de dossiers", "no_paths_added": "Aucun chemin n'a été ajouté", "no_pattern_added": "Aucun schéma d'exclusion n'a été ajouté", - "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment téléversés, exécutez la commande", + "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment envoyés, exécutez la commande", "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", @@ -228,14 +228,14 @@ "storage_template_hash_verification_enabled": "Vérification du hachage activée", "storage_template_hash_verification_enabled_description": "Active la vérification du hachage, ne désactivez pas cette option à moins d'être sûr de ce que vous faites", "storage_template_migration": "Migration du modèle de stockage", - "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment téléchargés", - "storage_template_migration_info": "Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment téléchargés, exécutez la tâche {job}.", + "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment envoyés", + "storage_template_migration_info": "Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment envoyés, exécutez la tâche {job}.", "storage_template_migration_job": "Tâche de migration du modèle de stockage", "storage_template_more_details": "Pour plus de détails sur cette fonctionnalité, reportez-vous au Modèle de stockage et à ses implications", "storage_template_onboarding_description": "Lorsqu'elle est activée, cette fonctionnalité réorganise les fichiers basés sur un modèle défini par l'utilisateur. En raison de problèmes de stabilité, la fonction a été désactivée par défaut. Pour plus d'informations, veuillez consulter la documentation.", "storage_template_path_length": "Limite approximative de la longueur du chemin : {length, number}/{limit, number}", "storage_template_settings": "Modèle de stockage", - "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média téléversé", + "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", "theme_custom_css_settings": "CSS personnalisé", @@ -311,7 +311,7 @@ "trash_settings": "Corbeille", "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", - "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, téléchargements interrompus, ou abandons en raison d'un bug", + "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -366,7 +366,7 @@ "allow_dark_mode": "Autoriser le mode sombre", "allow_edits": "Autoriser les modifications", "allow_public_user_to_download": "Permettre aux utilisateurs non connectés de télécharger", - "allow_public_user_to_upload": "Permettre aux utilisateurs non connectés de téléverser", + "allow_public_user_to_upload": "Permettre l'envoi aux utilisateurs non connectés", "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", "api_key_description": "Cette valeur ne sera affichée qu'une seule fois. Assurez-vous de la copier avant de fermer la fenêtre.", @@ -391,8 +391,9 @@ "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média est hors ligne. Immich ne peut pas accéder à son emplacement physique. Veuillez vous assurez que le média est disponible, puis relancez l'analyse de la bibliothèque.", "asset_skipped": "Sauté", - "asset_uploaded": "Téléversé", - "asset_uploading": "Chargement...", + "asset_skipped_in_trash": "À la corbeille", + "asset_uploaded": "Envoyé", + "asset_uploading": "Envoi...", "assets": "Médias", "assets_added_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}}", "assets_added_to_album_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}} à l'album", @@ -537,7 +538,7 @@ "download_settings_description": "Gérer les paramètres de téléchargement des médias", "downloading": "Téléchargement", "downloading_asset_filename": "Téléchargement du média {filename}", - "drop_files_to_upload": "Déposer des fichiers n'importe où pour téléverser", + "drop_files_to_upload": "Déposez les fichiers n'importe où pour envoyer", "duplicates": "Doublons", "duplicates_description": "Examiner chaque groupe et indiquer s'il y a des doublons", "duration": "Durée", @@ -613,7 +614,7 @@ "failed_to_remove_product_key": "Échec de suppression de la clé du produit", "failed_to_stack_assets": "Impossible d'empiler les médias", "failed_to_unstack_assets": "Impossible de dépiler les médias", - "import_path_already_exists": "Ce chemin d'import existe déjà.", + "import_path_already_exists": "Ce chemin d'importation existe déjà.", "incorrect_email_or_password": "Courriel ou mot de passe incorrect", "paths_validation_failed": "Validation échouée pour {paths, plural, one {# un chemin} other {# plusieurs chemins}}", "profile_picture_transparent_pixels": "Les images de profil ne peuvent pas avoir de pixels transparents. Veuillez agrandir et/ou déplacer l'image.", @@ -623,7 +624,7 @@ "unable_to_add_assets_to_shared_link": "Impossible d'ajouter des médias au lien partagé", "unable_to_add_comment": "Impossible d'ajouter un commentaire", "unable_to_add_exclusion_pattern": "Impossible d'ajouter un schéma d'exclusion", - "unable_to_add_import_path": "Impossible d'ajouter un chemin d'import", + "unable_to_add_import_path": "Impossible d'ajouter le chemin d'importation", "unable_to_add_partners": "Impossible d'ajouter des partenaires", "unable_to_add_remove_archive": "Impossible {archived, select, true {de supprimer des médias de} other {d'ajouter des médias à}} l'archive", "unable_to_add_remove_favorites": "Impossible {favorite, select, true {d'ajouter des médias aux} other {de supprimer des médias des}} favoris", @@ -648,18 +649,19 @@ "unable_to_delete_asset": "Suppression du média impossible", "unable_to_delete_assets": "Erreur lors de la suppression des médias", "unable_to_delete_exclusion_pattern": "Suppression du modèle d'exclusion impossible", - "unable_to_delete_import_path": "Suppression du chemin d'import impossible", + "unable_to_delete_import_path": "Suppression du chemin d'importation impossible", "unable_to_delete_shared_link": "Suppression du lien de partage impossible", "unable_to_delete_user": "Suppression de l'utilisateur impossible", "unable_to_download_files": "Impossible de télécharger les fichiers", "unable_to_edit_exclusion_pattern": "Modification du modèle d'exclusion impossible", - "unable_to_edit_import_path": "Modification du chemin d'import impossible", + "unable_to_edit_import_path": "Modification du chemin d'importation impossible", "unable_to_empty_trash": "Impossible de vider la corbeille", "unable_to_enter_fullscreen": "Mode plein écran indisponible", "unable_to_exit_fullscreen": "Sortie du mode plein écran impossible", "unable_to_get_comments_number": "Impossible d'obtenir le nombre de commentaires", "unable_to_get_shared_link": "Échec de la récupération du lien partagé", "unable_to_hide_person": "Impossible de cacher la personne", + "unable_to_link_motion_video": "Impossible de lier la photo animée", "unable_to_link_oauth_account": "Impossible de lier le compte OAuth", "unable_to_load_album": "Impossible de charger l'album", "unable_to_load_asset_activity": "Impossible de charger l'activité du média", @@ -700,6 +702,7 @@ "unable_to_submit_job": "Impossible d'exécuter la tâche", "unable_to_trash_asset": "Impossible de mettre le média à la corbeille", "unable_to_unlink_account": "Impossible de détacher le compte", + "unable_to_unlink_motion_video": "Impossible de détacher la photo animée", "unable_to_update_album_cover": "Impossible de mettre à jour la couverture de l'album", "unable_to_update_album_info": "Impossible de mettre à jour les informations de l'album", "unable_to_update_library": "Impossible de mettre à jour la bibliothèque", @@ -707,7 +710,7 @@ "unable_to_update_settings": "Impossible de mettre à jour les paramètres", "unable_to_update_timeline_display_status": "Impossible de mettre à jour le statut d'affichage de la timeline", "unable_to_update_user": "Impossible de mettre à jour l'utilisateur", - "unable_to_upload_file": "Impossible de téléverser le fichier" + "unable_to_upload_file": "Impossible d'envoyer le fichier" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -845,6 +848,7 @@ "license_trial_info_4": "Pensez à acheter une licence pour soutenir le développement du service", "light": "Clair", "like_deleted": "Réaction « j'aime » supprimée", + "link_motion_video": "Lier la photo animée", "link_options": "Options de lien", "link_to_oauth": "Lien au service OAuth", "linked_oauth_account": "Compte OAuth rattaché", @@ -913,10 +917,10 @@ "no_albums_with_name_yet": "Il semble que vous n'ayez pas encore d'albums avec ce nom.", "no_albums_yet": "Il semble que vous n'ayez pas encore d'album.", "no_archived_assets_message": "Archiver des photos et vidéos pour les masquer dans votre bibliothèque", - "no_assets_message": "CLIQUER ICI POUR IMPORTER VOTRE PREMIÈRE PHOTO", + "no_assets_message": "CLIQUER ICI POUR ENVOYER VOTRE PREMIÈRE PHOTO", "no_duplicates_found": "Aucun doublon n'a été trouvé.", "no_exif_info_available": "Aucune information exif disponible", - "no_explore_results_message": "Importer plus de photos pour explorer votre collection.", + "no_explore_results_message": "Envoyez plus de photos pour explorer votre collection.", "no_favorites_message": "Ajouter des photos et vidéos à vos favoris pour les retrouver plus rapidement", "no_libraries_message": "Créer une bibliothèque externe pour voir vos photos et vidéos dans un autre espace de stockage", "no_name": "Pas de nom", @@ -925,7 +929,7 @@ "no_results_description": "Essayez un synonyme ou un mot-clé plus général", "no_shared_albums_message": "Créer un album pour partager vos photos et vidéos avec les personnes de votre réseau", "not_in_any_album": "Dans aucun album", - "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà importés, lancer la", + "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà envoyés, lancer la", "note_unlimited_quota": "Note : Saisir 0 pour définir un quota illimité", "notes": "Notes", "notification_toggle_setting_description": "Activer les notifications par courriel", @@ -1134,6 +1138,7 @@ "search_for_existing_person": "Rechercher une personne existante", "search_no_people": "Aucune personne", "search_no_people_named": "Aucune personne nommée « {name} »", + "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", "search_state": "Rechercher par état/région...", @@ -1288,6 +1293,7 @@ "unknown_album": "", "unknown_year": "Année inconnue", "unlimited": "Illimité", + "unlink_motion_video": "Détacher la photo animée", "unlink_oauth": "Déconnecter OAuth", "unlinked_oauth_account": "Compte OAuth non connecté", "unnamed_album": "Album sans nom", @@ -1299,18 +1305,18 @@ "unstack": "Désempiler", "unstacked_assets_count": "{count, plural, one {# média dépilé} other {# médias dépilés}}", "untracked_files": "Fichiers non suivis", - "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, de téléchargements interrompus ou laissés pour compte à cause d'un bug", + "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, d'envois interrompus ou laissés pour compte à cause d'un bug", "up_next": "Suite", "updated_password": "Mot de passe mis à jour", - "upload": "Téléverser", - "upload_concurrency": "Envoi simultané", - "upload_errors": "Le téléversement s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload": "Envoyer", + "upload_concurrency": "Envois simultanés", + "upload_errors": "L'envoi s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias envoyés.", "upload_progress": "{remaining, number} restant(s) - {processed, number} traité(s)/{total, number}", "upload_skipped_duplicates": "{count, plural, one {# doublon ignoré} other {# doublons ignorés}}", "upload_status_duplicates": "Doublons", "upload_status_errors": "Erreurs", - "upload_status_uploaded": "Téléversé", - "upload_success": "Téléversement réussi. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload_status_uploaded": "Envoyé", + "upload_success": "Envoi réussi. Rafraîchir la page pour voir les nouveaux médias envoyés.", "url": "URL", "usage": "Utilisation", "use_custom_date_range": "Utilisez une plage de date personnalisée à la place", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index e2b8362568..05eab7a804 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -139,7 +139,11 @@ "map_settings_description": "נהל הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", - "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS ורזולוציה", + "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS, פנים ורזולוציה", + "metadata_faces_import_setting": "אפשר יבוא פנים", + "metadata_faces_import_setting_description": "יבא פנים מנתוני EXIF של תמונה ומקבצים נלווים", + "metadata_settings": "הגדרות מטא-נתונים", + "metadata_settings_description": "נהל הגדרות מטא-נתונים", "migration_job": "העברה", "migration_job_description": "העבר תמונות ממוזערות של נכסים ופנים למבנה התיקיות העדכני ביותר", "no_paths_added": "לא נוספו נתיבים", @@ -387,6 +391,7 @@ "asset_offline": "נכס לא מקוון", "asset_offline_description": "הנכס הזה אינו מקוון. Immich לא יכול לגשת למיקום הקובץ שלו. נא לוודא שהנכס זמין ואז סרוק מחדש את הספרייה.", "asset_skipped": "דילג", + "asset_skipped_in_trash": "באשפה", "asset_uploaded": "הועלה", "asset_uploading": "מעלה...", "assets": "נכסים", @@ -656,6 +661,7 @@ "unable_to_get_comments_number": "לא ניתן להשיג את מספר התגובות", "unable_to_get_shared_link": "קבלת קישור משותף נכשלה", "unable_to_hide_person": "לא ניתן להסתיר אדם", + "unable_to_link_motion_video": "לא ניתן לקשר סרטון תנועה", "unable_to_link_oauth_account": "לא ניתן לקשר חשבון OAuth", "unable_to_load_album": "לא ניתן לטעון אלבום", "unable_to_load_asset_activity": "לא ניתן לטעון את פעילות הנכס", @@ -696,6 +702,7 @@ "unable_to_submit_job": "לא ניתן לשלוח משימה", "unable_to_trash_asset": "לא ניתן להעביר נכס לאשפה", "unable_to_unlink_account": "לא ניתן לבטל קישור חשבון", + "unable_to_unlink_motion_video": "לא ניתן לבטל קישור סרטון תנועה", "unable_to_update_album_cover": "לא ניתן לעדכן עטיפת אלבום", "unable_to_update_album_info": "לא ניתן לעדכן פרטי אלבום", "unable_to_update_library": "לא ניתן לעדכן ספרייה", @@ -841,6 +848,7 @@ "license_trial_info_4": "אנא שקול לרכוש רישיון כדי לתמוך בפיתוח המתמשך של השירות", "light": "בהיר", "like_deleted": "לייק נמחק", + "link_motion_video": "קשר סרטון תנועה", "link_options": "אפשרויות קישור", "link_to_oauth": "קישור ל-OAuth", "linked_oauth_account": "חשבון OAuth מקושר", @@ -1130,6 +1138,7 @@ "search_for_existing_person": "חפש אדם קיים", "search_no_people": "אין אנשים", "search_no_people_named": "אין אנשים בשם \"{name}\"", + "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", "search_state": "חפש מדינה...", @@ -1208,6 +1217,8 @@ "sign_up": "הרשמה", "size": "גודל", "skip_to_content": "דלג לתוכן", + "skip_to_folders": "דלג לתיקיות", + "skip_to_tags": "דלג לתגים", "slideshow": "מצגת שקופיות", "slideshow_settings": "הגדרות מצגת שקופיות", "sort_albums_by": "מיין אלבומים לפי...", @@ -1282,6 +1293,7 @@ "unknown_album": "אלבום לא ידוע", "unknown_year": "שנה לא ידועה", "unlimited": "בלתי מוגבל", + "unlink_motion_video": "בטל קישור סרטון תנועה", "unlink_oauth": "בטל קישור OAuth", "unlinked_oauth_account": "בוטל קישור חשבון OAuth", "unnamed_album": "אלבום ללא שם", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 16d08bbfca..954eeff202 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -27,10 +27,11 @@ "added_to_favorites": "Dodano u omiljeno", "added_to_favorites_count": "Dodano {count, number} u omiljeno", "admin": { - "add_exclusion_pattern_description": "", + "add_exclusion_pattern_description": "Dodajte uzorke izuzimanja. Globiranje pomoću *, ** i ? je podržano. Za ignoriranje svih datoteka u bilo kojem direktoriju pod nazivom \"Raw\", koristite \"**/Raw/**\". Da biste zanemarili sve datoteke koje završavaju na \".tif\", koristite \"**/*.tif\". Da biste zanemarili apsolutni put, koristite \"/path/to/ignore/**\".", "authentication_settings": "Postavke autentikacije", "authentication_settings_description": "Uredi lozinku, OAuth, i druge postavke autentikacije", "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", + "authentication_settings_reenable": "Za ponovno uključivanje upotrijebite naredbu poslužitelja.", "background_task_job": "Pozadinski zadaci", "check_all": "Provjeri sve", "cleared_jobs": "Izbrisani poslovi za: {job}", @@ -72,8 +73,8 @@ "job_settings": "Postavke posla", "job_settings_description": "Upravljajte istovremenošću poslova", "job_status": "Status posla", - "jobs_delayed": "", - "jobs_failed": "", + "jobs_delayed": "{jobCount, plural, other {# delayed}}", + "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Stvorena biblioteka: {library}", "library_cron_expression": "Cron izraz", "library_cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", @@ -96,8 +97,8 @@ "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", "machine_learning_duplicate_detection": "Detekcija Duplikata", "machine_learning_duplicate_detection_enabled": "Omogući detekciju duplikata", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", + "machine_learning_duplicate_detection_enabled_description": "Ako je onemogućeno, potpuno identična sredstva i dalje će biti deduplicirana.", + "machine_learning_duplicate_detection_setting_description": "Upotrijebite CLIP ugradnje da biste pronašli vjerojatne duplikate", "machine_learning_enabled": "Uključi strojsko učenje", "machine_learning_enabled_description": "Ukoliko je ovo isključeno, sve funkcije strojnoga učenja biti će isključene bez obzira na postavke ispod.", "machine_learning_facial_recognition": "Detekcija lica", @@ -138,6 +139,10 @@ "map_style_description": "URL na style.json temu karte", "metadata_extraction_job": "Izdvoj metapodatke", "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "metadata_faces_import_setting": "Omogući uvoz lica", + "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", + "metadata_settings": "Postavke Metapodataka", + "metadata_settings_description": "Upravljanje postavkama metapodataka", "migration_job": "Migracija", "migration_job_description": "Premjestite minijature za sredstva i lica u najnoviju strukturu mapa", "no_paths_added": "Nema dodanih putanja", @@ -171,18 +176,20 @@ "oauth_enable_description": "Prijavite se putem OAutha", "oauth_issuer_url": "URL Izdavatelja", "oauth_mobile_redirect_uri": "Mobilnog Preusmjeravanja URI", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_scope": "", + "oauth_mobile_redirect_uri_override": "Nadjačavanje URI-preusmjeravanja za mobilne uređaje", + "oauth_mobile_redirect_uri_override_description": "Omogući kada pružatelj OAuth ne dopušta mobilni URI, poput '{callback}'", + "oauth_profile_signing_algorithm": "Algoritam za potpisivanje profila", + "oauth_profile_signing_algorithm_description": "Algoritam koji se koristi za potpisivanje korisničkog profila.", + "oauth_scope": "Opseg", "oauth_settings": "OAuth", "oauth_settings_description": "Upravljanje postavkama za prijavu kroz OAuth", "oauth_settings_more_details": "Za više pojedinosti o ovoj značajci pogledajte uputstva.", - "oauth_signing_algorithm": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", + "oauth_signing_algorithm": "Algoritam potpisivanja", + "oauth_storage_label_claim": "Potraživanje oznake za pohranu", + "oauth_storage_label_claim_description": "Automatski postavite korisničku oznaku za pohranu na vrijednost ovog zahtjeva.", + "oauth_storage_quota_claim": "Zahtjev za kvotom pohrane", + "oauth_storage_quota_claim_description": "Automatski postavite korisničku kvotu pohrane na vrijednost ovog zahtjeva.", + "oauth_storage_quota_default": "Zadana kvota pohrane (GiB)", "oauth_storage_quota_default_description": "Kvota u GiB koja će se koristiti kada nema zahtjeva (unesite 0 za neograničenu kvotu).", "offline_paths": "Izvanmrežne putanje", "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", @@ -196,8 +203,8 @@ "registration_description": "Budući da ste prvi korisnik na sustavu, bit ćete dodijeljeni administratorsku ulogu i odgovorni ste za administrativne poslove, a dodatne korisnike kreirat ćete sami.", "removing_offline_files": "Uklanjanje izvanmrežnih datoteka", "repair_all": "Popravi sve", - "repair_matched_items": "", - "repaired_items": "", + "repair_matched_items": "Podudaranje {count, plural, one {# item} other {# items}}", + "repaired_items": "Popravljeno {count, plural, one {# item} other {# items}}", "require_password_change_on_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", "reset_settings_to_default": "Vrati postavke na zadane", "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", @@ -210,25 +217,33 @@ "server_settings_description": "Upravljanje postavkama servera", "server_welcome_message": "Poruka dobrodošlice", "server_welcome_message_description": "Poruka koja je prikazana na prijavi.", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "system_settings": "", + "sidecar_job": "Sidecar metapodaci", + "sidecar_job_description": "Otkrijte ili sinkronizirajte sidecar metapodatke iz datotečnog sustava", + "slideshow_duration_description": "Broj sekundi za prikaz svake slike", + "smart_search_job_description": "Pokrenite strojno učenje na sredstvima za podršku pametnog pretraživanja", + "storage_template_date_time_description": "Vremenska oznaka stvaranja sredstva koristi se za informacije o datumu i vremenu", + "storage_template_date_time_sample": "Vrijeme uzorka {date}", + "storage_template_enable_description": "Omogući mehanizam predloška za pohranu", + "storage_template_hash_verification_enabled": "Omogućena hash provjera", + "storage_template_hash_verification_enabled_description": "Omogućuje hash provjeru, nemojte je onemogućiti osim ako niste sigurni u implikacije", + "storage_template_migration": "Migracija predloška za pohranu", + "storage_template_migration_description": "Primijenite trenutni {template} na prethodno prenesena sredstva", + "storage_template_migration_info": "Promjene predloška primjenjivat će se samo na nova sredstva. Za retroaktivnu primjenu predloška na prethodno prenesena sredstva, pokrenite {job}.", + "storage_template_migration_job": "Posao Migracije Predloška Pohrane", + "storage_template_more_details": "Za više pojedinosti o ovoj značajci pogledajte Predložak pohrane i njegove implikacije", + "storage_template_onboarding_description": "Kada je omogućena, ova će značajka automatski organizirati datoteke na temelju korisnički definiranog predloška. Zbog problema sa stabilnošću značajka je isključena prema zadanim postavkama. Za više informacija pogledajte dokumentaciju.", + "storage_template_path_length": "Približno ograničenje duljine putanje: {length, number}/{limit, number}", + "storage_template_settings": "Predložak pohrane", + "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", + "storage_template_user_label": "{label} je korisnička oznaka za pohranu", + "system_settings": "Postavke Sustava", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", "theme_settings": "Postavke tema", "theme_settings_description": "Upravljajte prilagodbom Immich web sučelja", - "these_files_matched_by_checksum": "", + "these_files_matched_by_checksum": "Ove datoteke se podudaraju prema njihovim kontrolnim zbrojevima", "thumbnail_generation_job": "Generirajte sličice", - "thumbnail_generation_job_description": "", + "thumbnail_generation_job_description": "Generirajte velike, male i zamućene sličice za svaki materijal, kao i sličice za svaku osobu", "transcoding_acceleration_api": "API ubrzanja", "transcoding_acceleration_api_description": "API koji će komunicirati s vašim uređajem radi ubrzanja transkodiranja. Ova postavka je 'najveći trud': vratit će se na softversko transkodiranje u slučaju kvara. VP9 može ili ne mora raditi ovisno o vašem hardveru.", "transcoding_acceleration_nvenc": "NVENC (zahtjeva NVIDIA GPU)", @@ -240,201 +255,290 @@ "transcoding_accepted_containers": "Prihvaćeni kontenjeri", "transcoding_accepted_containers_description": "Odaberite koji formati spremnika ne moraju biti remulksirani u MP4. Koristi se samo za određena pravila transkodiranja.", "transcoding_accepted_video_codecs": "Prihvaćeni video kodeci", - "transcoding_accepted_video_codecs_description": "", + "transcoding_accepted_video_codecs_description": "Odaberite koje video kodeke nije potrebno transkodirati. Koristi se samo za određena pravila transkodiranja.", "transcoding_advanced_options_description": "Postavke većina korisnika ne treba mjenjati", "transcoding_audio_codec": "Audio kodek", "transcoding_audio_codec_description": "Opus je opcija s najvećom kvalitetom, no ima manju podršku s starim uređajima i softverima.", "transcoding_bitrate_description": "Videozapisi veći od maksimalne brzine prijenosa ili nisu u prihvatljivom formatu", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_codecs_learn_more": "Da biste saznali više o terminologiji koja se ovdje koristi, pogledajte FFmpeg dokumentaciju za H.264 kodek, HEVC kodek i VP9 kodek.", + "transcoding_constant_quality_mode": "Način stalne kvalitete", + "transcoding_constant_quality_mode_description": "ICQ je bolji od CQP-a, ali neki uređaji za hardversko ubrzanje ne podržavaju ovaj način rada. Postavljanje ove opcije daje prednost navedenom načinu rada kada se koristi kodiranje temeljeno na kvaliteti. NVENC je zanemaren jer ne podržava ICQ.", + "transcoding_constant_rate_factor": "Faktor konstantne stope (-crf)", + "transcoding_constant_rate_factor_description": "Razina kvalitete videa. Uobičajene vrijednosti su 23 za H.264, 28 za HEVC, 31 za VP9 i 35 za AV1. Niže je bolje, ali stvara veće datoteke.", + "transcoding_disabled_description": "Nemojte transkodirati nijedan videozapis, može prekinuti reprodukciju na nekim klijentima", + "transcoding_hardware_acceleration": "Hardversko Ubrzanje", + "transcoding_hardware_acceleration_description": "Eksperimentalno; puno brže, ali će imati nižu kvalitetu pri istoj bitrate postavci", + "transcoding_hardware_decoding": "Hardversko dekodiranje", + "transcoding_hardware_decoding_setting_description": "Odnosi se samo na NVENC, QSV i RKMPP. Omogućuje ubrzanje s kraja na kraj umjesto samo ubrzavanja kodiranja. Možda neće raditi na svim videozapisima.", "transcoding_hevc_codec": "HEVC kodek", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames": "Maksimalni B-frameovi", + "transcoding_max_b_frames_description": "Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. Možda nije kompatibilan s hardverskim ubrzanjem na starijim uređajima. 0 onemogućuje B-frameove, dok -1 automatski postavlja ovu vrijednost.", "transcoding_max_bitrate": "Maksimalne brzina prijenosa (bitrate)", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" + "transcoding_max_bitrate_description": "Postavljanje maksimalne brzine prijenosa može učiniti veličine datoteka predvidljivijima uz manji trošak za kvalitetu. Pri 720p, tipične vrijednosti su 2600k za VP9 ili HEVC ili 4500k za H.264. Onemogućeno ako je postavljeno na 0.", + "transcoding_max_keyframe_interval": "Maksimalni interval ključnih sličica", + "transcoding_max_keyframe_interval_description": "Postavlja maksimalnu udaljenost slika između ključnih kadrova. Niže vrijednosti pogoršavaju učinkovitost kompresije, ali poboljšavaju vrijeme traženja i mogu poboljšati kvalitetu u scenama s brzim kretanjem. 0 automatski postavlja ovu vrijednost.", + "transcoding_optimal_description": "Videozapisi koji su veći od ciljne rezolucije ili nisu u prihvatljivom formatu", + "transcoding_preferred_hardware_device": "Preferirani hardverski uređaj", + "transcoding_preferred_hardware_device_description": "Odnosi se samo na VAAPI i QSV. Postavlja dri node koji se koristi za hardversko transkodiranje.", + "transcoding_preset_preset": "Preset (-preset)", + "transcoding_preset_preset_description": "Brzina kompresije. Sporije postavke proizvode manje datoteke i povećavaju kvalitetu pri ciljanju određene postavke bitratea. VP9 zanemaruje brzine iznad 'brže'.", + "transcoding_reference_frames": "Referentne slike", + "transcoding_reference_frames_description": "Broj slika za referencu prilikom komprimiranja određene slike. Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. 0 automatski postavlja ovu vrijednost.", + "transcoding_required_description": "Samo videozapisi koji nisu u prihvaćenom formatu", + "transcoding_settings": "Postavke Video Transkodiranja", + "transcoding_settings_description": "Upravljajte informacijama o razlučivosti i kodiranju video datoteka", + "transcoding_target_resolution": "Ciljana rezolucija", + "transcoding_target_resolution_description": "Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odziv aplikacije.", + "transcoding_temporal_aq": "Vremenski AQ", + "transcoding_temporal_aq_description": "Odnosi se samo na NVENC. Povećava kvalitetu scena s puno detalja i malo pokreta. Možda nije kompatibilan sa starijim uređajima.", + "transcoding_threads": "Sljedovi (Threads)", + "transcoding_threads_description": "Više vrijednosti dovode do bržeg kodiranja, ali ostavljaju manje prostora poslužitelju za obradu drugih zadataka dok je aktivan. Ova vrijednost ne smije biti veća od broja CPU jezgri. Maksimalno povećava iskorištenje ako je postavljeno na 0.", + "transcoding_tone_mapping": "Tonsko preslikavanje", + "transcoding_tone_mapping_description": "Pokušava sačuvati izgled HDR videozapisa kada se pretvori u SDR. Svaki algoritam čini različite kompromise za boju, detalje i svjetlinu. Hable čuva detalje, Mobius čuva boju, a Reinhard svjetlinu.", + "transcoding_tone_mapping_npl": "Tone-mapping NPL", + "transcoding_tone_mapping_npl_description": "Boje će se prilagoditi tako da izgledaju normalno za zaslon ove svjetline. Suprotno intuiciji, niže vrijednosti povećavaju svjetlinu videa i obrnuto budući da kompenziraju svjetlinu zaslona. 0 automatski postavlja ovu vrijednost.", + "transcoding_transcode_policy": "Pravila transkodiranja", + "transcoding_transcode_policy_description": "Pravila o tome kada se video treba transkodirati. HDR videozapisi uvijek će biti transkodirani (osim ako je transkodiranje onemogućeno).", + "transcoding_two_pass_encoding": "Kodiranje u dva prolaza", + "transcoding_two_pass_encoding_setting_description": "Transkodiranje u dva prolaza za proizvodnju bolje kodiranih videozapisa. Kada je omogućena maksimalna brzina prijenosa (potrebna za rad s H.264 i HEVC), ovaj način rada koristi raspon brzine prijenosa na temelju maksimalne brzine prijenosa i zanemaruje CRF. Za VP9, CRF se može koristiti ako je maksimalna brzina prijenosa onemogućena.", + "transcoding_video_codec": "Video Kodek", + "transcoding_video_codec_description": "VP9 ima visoku učinkovitost i web-kompatibilnost, ali treba dulje za transkodiranje. HEVC ima sličnu izvedbu, ali ima slabiju web kompatibilnost. H.264 široko je kompatibilan i brzo se transkodira, ali proizvodi mnogo veće datoteke. AV1 je najučinkovitiji kodek, ali nema podršku na starijim uređajima.", + "trash_enabled_description": "Omogućite značajke Smeća", + "trash_number_of_days": "Broj dana", + "trash_number_of_days_description": "Broj dana za držanje sredstava u smeću prije njihovog trajnog uklanjanja", + "trash_settings": "Postavke Smeća", + "trash_settings_description": "Upravljanje postavkama smeća", + "untracked_files": "Nepraćene datoteke", + "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", + "user_delete_delay_settings": "Brisanje odgode", + "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", + "user_delete_immediately": "Račun i sredstva korisnika {user} bit će stavljeni u red čekanja za trajno brisanje odmah.", + "user_delete_immediately_checkbox": "Stavite korisnika i imovinu u red za trenutačno brisanje", + "user_management": "Upravljanje Korisnicima", + "user_password_has_been_reset": "Korisnička lozinka je poništena:", + "user_password_reset_description": "Molimo dostavite privremenu lozinku korisniku i obavijestite ga da će morati promijeniti lozinku pri sljedećoj prijavi.", + "user_restore_description": "Račun korisnika {user} bit će vraćen.", + "user_restore_scheduled_removal": "Vrati korisnika - zakazano uklanjanje {date, date, long}", + "user_settings": "Korisničke Postavke", + "user_settings_description": "Upravljanje korisničkim postavkama", + "user_successfully_removed": "Korisnik {email} je uspješno uklonjen.", + "version_check_enabled_description": "Omogući provjeru verzije", + "version_check_implications": "Značajka provjere verzije oslanja se na periodičnu komunikaciju s github.com", + "version_check_settings": "Provjera Verzije", + "version_check_settings_description": "Omogućite/onemogućite obavijest o novoj verziji", + "video_conversion_job": "Transkodiranje videozapisa", + "video_conversion_job_description": "Transkodiranje videozapisa za veću kompatibilnost s preglednicima i uređajima" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "admin_email": "E-pošta administratora", + "admin_password": "Admin Lozinka", + "administration": "Administracija", + "advanced": "Napredno", + "age_months": "Dob {months, plural, one {# month} other {# months}}", + "age_year_months": "Dob 1 godina, {months, plural, one {# month} other {# months}}", + "age_years": "{years, plural, other {Age #}}", + "album_added": "Album dodan", + "album_added_notification_setting_description": "Primite obavijest e-poštom kada ste dodani u dijeljeni album", + "album_cover_updated": "Naslovnica albuma ažurirana", + "album_delete_confirmation": "Jeste li sigurni da želite izbrisati album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album dijeli, drugi korisnici mu više neće moći pristupiti.", + "album_info_updated": "Podaci o albumu ažurirani", + "album_leave": "Napustiti album?", + "album_leave_confirmation": "Jeste li sigurni da želite napustiti {album}?", + "album_name": "Naziv Albuma", + "album_options": "Opcije albuma", + "album_remove_user": "Ukloni korisnika?", + "album_remove_user_confirmation": "Jeste li sigurni da želite ukloniti {user}?", + "album_share_no_users": "Čini se da ste podijelili ovaj album sa svim korisnicima ili nemate nijednog korisnika s kojim biste ga dijelili.", + "album_updated": "Album ažuriran", + "album_updated_setting_description": "Primite obavijest e-poštom kada dijeljeni album ima nova sredstva", + "album_user_left": "Napušten {album}", + "album_user_removed": "Uklonjen {user}", + "album_with_link_access": "Dopusti svima s poveznicom pristup fotografijama i osobama u ovom albumu.", + "albums": "Albumi", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albumi}}", + "all": "Sve", + "all_albums": "Svi albumi", + "all_people": "Svi ljudi", + "all_videos": "Svi videi", + "allow_dark_mode": "Dozvoli tamni način", + "allow_edits": "Dozvoli izmjene", + "allow_public_user_to_download": "Dopusti javnom korisniku preuzimanje", + "allow_public_user_to_upload": "Dopusti javnom korisniku učitavanje", + "anti_clockwise": "Suprotno smjeru kazaljke na satu", + "api_key": "API Ključ", + "api_key_description": "Ova će vrijednost biti prikazana samo jednom. Obavezno ju kopirajte prije zatvaranja prozora.", + "api_key_empty": "Naziv vašeg API ključa ne smije biti prazan", + "api_keys": "API Ključevi", + "app_settings": "Postavke Aplikacije", + "appears_in": "Pojavljuje se u", + "archive": "Arhiva", + "archive_or_unarchive_photo": "Arhivirajte ili dearhivirajte fotografiju", + "archive_size": "Veličina arhive", + "archive_size_description": "Konfigurirajte veličinu arhive za preuzimanja (u GiB)", "archived": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", + "archived_count": "{count, plural, other {Archived #}}", + "are_these_the_same_person": "Je li ovo ista osoba?", + "are_you_sure_to_do_this": "Jeste li sigurni da to želite učiniti?", + "asset_added_to_album": "Dodano u album", + "asset_adding_to_album": "Dodavanje u album...", + "asset_description_updated": "Opis imovine je ažuriran", + "asset_filename_is_offline": "Sredstvo {filename} je izvan mreže", + "asset_has_unassigned_faces": "Materijal ima nedodijeljena lica", + "asset_hashing": "Hashiranje...", + "asset_offline": "Sredstvo izvan mreže", + "asset_offline_description": "Ovaj materijal je izvan mreže. Immich ne može pristupiti lokaciji datoteke. Provjerite je li sredstvo dostupno, a zatim ponovno skenirajte biblioteku.", + "asset_skipped": "Preskočeno", + "asset_skipped_in_trash": "U smeću", + "asset_uploaded": "Učitano", + "asset_uploading": "Učitavanje...", + "assets": "Sredstva", + "assets_added_count": "Dodano {count, plural, one {# asset} other {# assets}}", + "assets_added_to_album_count": "Dodano {count, plural, one {# asset} other {# assets}} u album", + "assets_added_to_name_count": "Dodano {count, plural, one {# asset} other {# assets}} u {hasName, select, true {{name}} other {new album}}", + "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "{count, plural, one {# asset} other {# asset}} premješteno u smeće", + "assets_permanently_deleted_count": "Trajno izbrisano {count, plural, one {# asset} other {# assets}}", + "assets_removed_count": "Uklonjeno {count, plural, one {# asset} other {# assets}}", + "assets_restore_confirmation": "Jeste li sigurni da želite vratiti sve svoje resurse bačene u otpad? Ne možete poništiti ovu radnju!", + "assets_restored_count": "Vraćeno {count, plural, one {# asset} other {# assets}}", + "assets_trashed_count": "Bačeno u smeće {count, plural, one {# asset} other {# assets}}", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} već dio albuma", + "authorized_devices": "Ovlašteni Uređaji", + "back": "Nazad", + "back_close_deselect": "Natrag, zatvorite ili poništite odabir", + "backward": "Unazad", + "birthdate_saved": "Datum rođenja uspješno spremljen", + "birthdate_set_description": "Datum rođenja se koristi za izračunavanje godina ove osobe u trenutku fotografije.", + "blurred_background": "Zamućena pozadina", + "build": "Sagradi (Build)", + "build_image": "Sagradi (Build) Image", + "bulk_delete_duplicates_confirmation": "Jeste li sigurni da želite skupno izbrisati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i trajno izbrisati sve druge duplikate. Ne možete poništiti ovu radnju!", + "bulk_keep_duplicates_confirmation": "Jeste li sigurni da želite zadržati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će riješiti sve duplicirane grupe bez brisanja ičega.", + "bulk_trash_duplicates_confirmation": "Jeste li sigurni da želite na veliko baciti u smeće {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i baciti sve ostale duplikate u smeće.", + "buy": "Kupi Immich", + "camera": "Kamera", + "camera_brand": "Marka kamere", + "camera_model": "Model kamere", + "cancel": "Otkaži", + "cancel_search": "Otkaži pretragu", + "cannot_merge_people": "Nije moguće spojiti osobe", + "cannot_undo_this_action": "Ne možete poništiti ovu radnju!", + "cannot_update_the_description": "Nije moguće ažurirati opis", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", - "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", - "delete_api_key_prompt": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "downloading": "", - "duration": "", + "change_date": "Promjena datuma", + "change_expiration_time": "Promjena vremena isteka", + "change_location": "Promjena lokacije", + "change_name": "Promjena imena", + "change_name_successfully": "Promijena imena uspješna", + "change_password": "Promjena Lozinke", + "change_password_description": "Ovo je ili prvi put da se prijavljujete u sustav ili je poslan zahtjev za promjenom lozinke. Unesite novu lozinku ispod.", + "change_your_password": "Promijenite lozinku", + "changed_visibility_successfully": "Vidljivost je uspješno promijenjena", + "check_all": "Provjeri Sve", + "check_logs": "Provjera Zapisa", + "choose_matching_people_to_merge": "Odaberite odgovarajuće osobe za spajanje", + "city": "Grad", + "clear": "Očisti", + "clear_all": "Očisti sve", + "clear_all_recent_searches": "Izbriši sva nedavna pretraživanja", + "clear_message": "Jasna poruka", + "clear_value": "Očisti vrijednost", + "clockwise": "U smjeru kazaljke na satu", + "close": "Zatvori", + "collapse": "Sažimanje", + "collapse_all": "Sažmi sve", + "color": "Boja", + "color_theme": "Tema boja", + "comment_deleted": "Komentar izbrisan", + "comment_options": "Opcije komentara", + "comments_and_likes": "Komentari i lajkovi", + "comments_are_disabled": "Komentari onemogućeni", + "confirm": "Potvrdi", + "confirm_admin_password": "Potvrdite lozinku administratora", + "confirm_delete_shared_link": "Jeste li sigurni da želite izbrisati ovu zajedničku vezu?", + "confirm_password": "Potvrdite lozinku", + "contain": "Sadrži", + "context": "Kontekst", + "continue": "Nastavi", + "copied_image_to_clipboard": "Slika je kopirana u međuspremnik.", + "copied_to_clipboard": "Kopirano u međuspremnik!", + "copy_error": "Greška kopiranja", + "copy_file_path": "Kopiraj put datoteke", + "copy_image": "Kopiraj Sliku", + "copy_link": "Kopiraj poveznicu", + "copy_link_to_clipboard": "Kopiraj poveznicu u međuspremnik", + "copy_password": "Kopiraj lozinku", + "copy_to_clipboard": "Kopiraj u međuspremnik", + "country": "Država", + "cover": "Naslovnica", + "covers": "Naslovnice", + "create": "Kreiraj", + "create_album": "Kreiraj album", + "create_library": "Kreiraj Biblioteku", + "create_link": "Kreiraj poveznicu", + "create_link_to_share": "Izradite vezu za dijeljenje", + "create_link_to_share_description": "Dopusti svakome s vezom da vidi odabrane fotografije", + "create_new_person": "Stvorite novu osobu", + "create_new_person_hint": "Dodijelite odabrana sredstva novoj osobi", + "create_new_user": "Kreiraj novog korisnika", + "create_tag": "Stvori oznaku", + "create_tag_description": "Napravite novu oznaku. Za ugniježđene oznake unesite punu putanju oznake uključujući kose crte.", + "create_user": "Stvori korisnika", + "created": "Stvoreno", + "current_device": "Trenutačni uređaj", + "custom_locale": "Prilagođena Lokalizacija", + "custom_locale_description": "Formatiranje datuma i brojeva na temelju jezika i regije", + "dark": "Tamno", + "date_after": "Datum nakon", + "date_and_time": "Datum i Vrijeme", + "date_before": "Datum prije", + "date_of_birth_saved": "Datum rođenja uspješno spremljen", + "date_range": "Razdoblje", + "day": "Dan", + "deduplicate_all": "Dedupliciraj Sve", + "default_locale": "Zadana lokalizacija", + "default_locale_description": "Oblikujte datume i brojeve na temelju jezika preglednika", + "delete": "Izbriši", + "delete_album": "Izbriši album", + "delete_api_key_prompt": "Jeste li sigurni da želite izbrisati ovaj API ključ?", + "delete_duplicates_confirmation": "Jeste li sigurni da želite trajno izbrisati ove duplikate?", + "delete_key": "Ključ za brisanje", + "delete_library": "Izbriši knjižnicu", + "delete_link": "Izbriši poveznicu", + "delete_shared_link": "Izbriši dijeljenu poveznicu", + "delete_tag": "Izbriši oznaku", + "delete_tag_confirmation_prompt": "Jeste li sigurni da želite izbrisati oznaku {tagName}?", + "delete_user": "Izbriši korisnika", + "deleted_shared_link": "Izbrisana dijeljena poveznica", + "description": "Opis", + "details": "Detalji", + "direction": "Smjer", + "disabled": "Onemogućeno", + "disallow_edits": "Zabrani izmjene", + "discover": "Otkrij", + "dismiss_all_errors": "Odbaci sve pogreške", + "dismiss_error": "Odbaci pogrešku", + "display_options": "Mogućnosti prikaza", + "display_order": "Redoslijed prikaza", + "display_original_photos": "Prikaz originalnih fotografija", + "display_original_photos_setting_description": "Radije prikažite izvornu fotografiju kada gledate materijal umjesto sličica kada je izvorni materijal kompatibilan s webom. To može rezultirati sporijim brzinama prikaza fotografija.", + "do_not_show_again": "Ne prikazuj više ovu poruku", + "done": "Gotovo", + "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni videozapisi", + "download_include_embedded_motion_videos_description": "Uključite videozapise ugrađene u fotografije s pokretom kao zasebnu datoteku", + "download_settings": "Preuzmi", + "download_settings_description": "Upravljajte postavkama koje se odnose na preuzimanje sredstava", + "downloading": "Preuzimanje", + "downloading_asset_filename": "Preuzimanje materijala {filename}", + "drop_files_to_upload": "Ispustite datoteke bilo gdje za prijenos", + "duplicates": "Duplikati", + "duplicates_description": "Razriješite svaku grupu tako da naznačite koji su duplikati, ako ih ima", + "duration": "Trajanje", "durations": { "days": "", "hours": "", @@ -442,254 +546,378 @@ "months": "", "years": "" }, - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", + "edit": "Izmjena", + "edit_album": "Uredi album", + "edit_avatar": "Uredi avatar", + "edit_date": "Uredi datum", + "edit_date_and_time": "Uredite datum i vrijeme", + "edit_exclusion_pattern": "Uredi uzorak izuzimanja", + "edit_faces": "Uređivanje lica", + "edit_import_path": "Uredi put uvoza", + "edit_import_paths": "Uredi Uvozne Putanje", + "edit_key": "Ključ za uređivanje", + "edit_link": "Uredi poveznicu", + "edit_location": "Uredi lokaciju", + "edit_name": "Uredi ime", + "edit_people": "Uredi ljude", + "edit_tag": "Uredi oznaku", + "edit_title": "Uredi Naslov", + "edit_user": "Uredi korisnika", + "edited": "Uređeno", + "editor": "Urednik", + "editor_close_without_save_prompt": "Promjene neće biti spremljene", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Omjeri stranica", + "editor_crop_tool_h2_rotation": "Rotacija", + "email": "E-pošta", "empty_album": "", - "empty_trash": "", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "empty_trash": "Isprazni smeće", + "empty_trash_confirmation": "Jeste li sigurni da želite isprazniti smeće? Time će se iz Immicha trajno ukloniti sva sredstva u otpadu.\nNe možete poništiti ovu radnju!", + "enable": "Omogući", + "enabled": "Omogućeno", + "end_date": "Datum završetka", + "error": "Greška", + "error_loading_image": "Pogreška pri učitavanju slike", + "error_title": "Greška - Nešto je pošlo krivo", "errors": { - "cleared_jobs": "", - "exclusion_pattern_already_exists": "", - "failed_job_command": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" + "cannot_navigate_next_asset": "Nije moguće prijeći na sljedeći materijal", + "cannot_navigate_previous_asset": "Nije moguće prijeći na prethodni materijal", + "cant_apply_changes": "Nije moguće primijeniti promjene", + "cant_change_activity": "Ne mogu {enabled, select, true {disable} druge {enable}} aktivnosti", + "cant_change_asset_favorite": "Nije moguće promijeniti favorita za sredstvo", + "cant_change_metadata_assets_count": "Nije moguće promijeniti metapodatke {count, plural, one {# asset} other {# assets}}", + "cant_get_faces": "Ne mogu dobiti lica", + "cant_get_number_of_comments": "Ne mogu dobiti broj komentara", + "cant_search_people": "Ne mogu pretraživati ljude", + "cant_search_places": "Ne mogu pretraživati mjesta", + "cleared_jobs": "Izbrisani poslovi za: {job}", + "error_adding_assets_to_album": "Pogreška pri dodavanju materijala u album", + "error_adding_users_to_album": "Pogreška pri dodavanju korisnika u album", + "error_deleting_shared_user": "Pogreška pri brisanju dijeljenog korisnika", + "error_downloading": "Pogreška pri preuzimanju {filename}", + "error_hiding_buy_button": "Pogreška pri skrivanju gumba za kupnju", + "error_removing_assets_from_album": "Pogreška prilikom uklanjanja materijala iz albuma, provjerite konzolu za više pojedinosti", + "error_selecting_all_assets": "Pogreška pri odabiru svih sredstava", + "exclusion_pattern_already_exists": "Ovaj uzorak izuzimanja već postoji.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", + "failed_to_create_album": "Izrada albuma nije uspjela", + "failed_to_create_shared_link": "Stvaranje dijeljene veze nije uspjelo", + "failed_to_edit_shared_link": "Nije uspjelo uređivanje dijeljene poveznice", + "failed_to_get_people": "Dohvaćanje ljudi nije uspjelo", + "failed_to_load_asset": "Učitavanje sredstva nije uspjelo", + "failed_to_load_assets": "Učitavanje sredstava nije uspjelo", + "failed_to_load_people": "Učitavanje ljudi nije uspjelo", + "failed_to_remove_product_key": "Uklanjanje ključa proizvoda nije uspjelo", + "failed_to_stack_assets": "Slaganje sredstava nije uspjelo", + "failed_to_unstack_assets": "Nije uspjelo uklanjanje snopa sredstava", + "import_path_already_exists": "Ovaj uvozni put već postoji.", + "incorrect_email_or_password": "Netočna adresa e-pošte ili lozinka", + "paths_validation_failed": "{paths, plural, one {# putanja nije prošla} other {# putanje nisu prošle}} provjeru valjanosti", + "profile_picture_transparent_pixels": "Profilne slike ne smiju imati prozirne piksele. Povećajte i/ili pomaknite sliku.", + "quota_higher_than_disk_size": "Postavili ste kvotu veću od veličine diska", + "repair_unable_to_check_items": "Nije moguće provjeriti {count, select, one {item} other {items}}", + "unable_to_add_album_users": "Nije moguće dodati korisnike u album", + "unable_to_add_assets_to_shared_link": "Nije moguće dodati sredstva na dijeljenu poveznicu", + "unable_to_add_comment": "Nije moguće dodati komentar", + "unable_to_add_exclusion_pattern": "Nije moguće dodati uzorak izuzimanja", + "unable_to_add_import_path": "Nije moguće dodati putanju uvoza", + "unable_to_add_partners": "Nije moguće dodati partnere", + "unable_to_add_remove_archive": "Nije moguće {arhivirano, odabrati, istinito {ukloniti sredstvo iz} druge {dodati sredstvo u}} arhivu", + "unable_to_add_remove_favorites": "Nije moguće {favorite, select, true {add asset to} other {remove asset from}} favorite", + "unable_to_archive_unarchive": "Nije moguće {arhivirati, odabrati, istinito {arhivirati} ostalo {dearhivirati}}", + "unable_to_change_album_user_role": "Nije moguće promijeniti ulogu korisnika albuma", + "unable_to_change_date": "Nije moguće promijeniti datum", + "unable_to_change_favorite": "Nije moguće promijeniti favorita za sredstvo", + "unable_to_change_location": "Nije moguće promijeniti lokaciju", + "unable_to_change_password": "Nije moguće promijeniti lozinku", + "unable_to_change_visibility": "Nije moguće promijeniti vidljivost za {count, plural, one {# osobu} other {# osobe}}", + "unable_to_complete_oauth_login": "Nije moguće dovršiti OAuth prijavu", + "unable_to_connect": "Povezivanje nije moguće", + "unable_to_connect_to_server": "Nije moguće spojiti se na poslužitelj", + "unable_to_copy_to_clipboard": "Nije moguće kopirati u međuspremnik, provjerite pristupate li stranici putem https-a", + "unable_to_create_admin_account": "Nije moguće stvoriti administratorski račun", + "unable_to_create_api_key": "Nije moguće izraditi novi API ključ", + "unable_to_create_library": "Nije moguće stvoriti biblioteku", + "unable_to_create_user": "Nije moguće stvoriti korisnika", + "unable_to_delete_album": "Nije moguće izbrisati album", + "unable_to_delete_asset": "Nije moguće izbrisati sredstvo", + "unable_to_delete_assets": "Pogreška pri brisanju sredstava", + "unable_to_delete_exclusion_pattern": "Nije moguće izbrisati uzorak izuzimanja", + "unable_to_delete_import_path": "Nije moguće izbrisati put uvoza", + "unable_to_delete_shared_link": "Nije moguće izbrisati dijeljenu poveznicu", + "unable_to_delete_user": "Nije moguće izbrisati korisnika", + "unable_to_download_files": "Nije moguće preuzeti datoteke", + "unable_to_edit_exclusion_pattern": "Nije moguće urediti uzorak izuzimanja", + "unable_to_edit_import_path": "Nije moguće urediti put uvoza", + "unable_to_empty_trash": "Nije moguće isprazniti otpad", + "unable_to_enter_fullscreen": "Nije moguće otvoriti cijeli zaslon", + "unable_to_exit_fullscreen": "Nije moguće izaći iz cijelog zaslona", + "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", + "unable_to_get_shared_link": "Dohvaćanje dijeljene veze nije uspjelo", + "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati videozapis pokreta", + "unable_to_link_oauth_account": "Nije moguće povezati OAuth račun", + "unable_to_load_album": "Nije moguće učitati album", + "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstva", + "unable_to_load_items": "Nije moguće učitati stavke", + "unable_to_load_liked_status": "Nije moguće učitati status sviđanja", + "unable_to_log_out_all_devices": "Nije moguće odjaviti sve uređaje", + "unable_to_log_out_device": "Nije moguće odjaviti uređaj", + "unable_to_login_with_oauth": "Nije moguće prijaviti se pomoću OAutha", + "unable_to_play_video": "Nije moguće reproducirati video", + "unable_to_reassign_assets_existing_person": "Nije moguće ponovno dodijeliti imovinu na {name, select, null {postojeću osobu} other {{name}}}", + "unable_to_reassign_assets_new_person": "Nije moguće ponovno dodijeliti imovinu novoj osobi", + "unable_to_refresh_user": "Nije moguće osvježiti korisnika", + "unable_to_remove_album_users": "Nije moguće ukloniti korisnike iz albuma", + "unable_to_remove_api_key": "Nije moguće ukloniti API ključ", + "unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti sredstva iz dijeljene poveznice", + "unable_to_remove_library": "Nije moguće ukloniti biblioteku", + "unable_to_remove_offline_files": "Nije moguće ukloniti izvanmrežne datoteke", + "unable_to_remove_partner": "Nije moguće ukloniti partnera", + "unable_to_remove_reaction": "Nije moguće ukloniti reakciju", + "unable_to_repair_items": "Nije moguće popraviti stavke", + "unable_to_reset_password": "Nije moguće ponovno postaviti lozinku", + "unable_to_resolve_duplicate": "Nije moguće razriješiti duplikat", + "unable_to_restore_assets": "Nije moguće vratiti imovinu", + "unable_to_restore_trash": "Nije moguće vratiti otpad", + "unable_to_restore_user": "Nije moguće vratiti korisnika", + "unable_to_save_album": "Nije moguće spremiti album", + "unable_to_save_api_key": "Nije moguće spremiti API ključ", + "unable_to_save_date_of_birth": "Nije moguće spremiti datum rođenja", + "unable_to_save_name": "Nije moguće spremiti ime", + "unable_to_save_profile": "Nije moguće spremiti profil", + "unable_to_save_settings": "Nije moguće spremiti postavke", + "unable_to_scan_libraries": "Nije moguće skenirati knjižnice", + "unable_to_scan_library": "Nije moguće skenirati knjižnicu", + "unable_to_set_feature_photo": "Nije moguće postaviti istaknutu fotografiju", + "unable_to_set_profile_picture": "Nije moguće postaviti profilnu sliku", + "unable_to_submit_job": "Nije moguće poslati posao", + "unable_to_trash_asset": "Nije moguće baciti sredstvo u smeće", + "unable_to_unlink_account": "Nije moguće prekinuti vezu računa", + "unable_to_unlink_motion_video": "Nije moguće prekinuti vezu videozapisa pokreta", + "unable_to_update_album_cover": "Nije moguće ažurirati omot albuma", + "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", + "unable_to_update_library": "Nije moguće ažurirati biblioteku", + "unable_to_update_location": "Nije moguće ažurirati lokaciju", + "unable_to_update_settings": "Nije moguće ažurirati postavke", + "unable_to_update_timeline_display_status": "Nije moguće ažurirati status prikaza vremenske trake", + "unable_to_update_user": "Nije moguće ažurirati korisnika", + "unable_to_upload_file": "Nije moguće učitati datoteku" }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", + "exif": "Exif", + "exit_slideshow": "Izađi iz projekcije slideova", + "expand_all": "Proširi sve", + "expire_after": "Istječe nakon", + "expired": "Isteklo", + "expires_date": "Ističe {date}", + "explore": "Istraži", + "explorer": "Pretraživač (Explorer)", + "export": "Izvoz", + "export_as_json": "Izvezi kao JSON", + "extension": "Proširenje (Extension)", + "external": "Vanjski", + "external_libraries": "Vanjske Biblioteke", + "face_unassigned": "Nedodijeljeno", "failed_to_get_people": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", - "find_them_fast": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", + "favorite": "Omiljeno", + "favorite_or_unfavorite_photo": "Omiljena ili neomiljena fotografija", + "favorites": "Omiljene", + "feature_photo_updated": "Istaknuta fotografija ažurirana", + "features": "Značajke (Features)", + "features_setting_description": "Upravljajte značajkama aplikacije", + "file_name": "Naziv datoteke", + "file_name_or_extension": "Naziv ili ekstenzija datoteke", + "filename": "Naziv datoteke", + "filetype": "Vrsta datoteke", + "filter_people": "Filtrirajte ljude", + "find_them_fast": "Pronađite ih brzo po imenu pomoću pretraživanja", + "fix_incorrect_match": "Ispravite netočno podudaranje", + "folders": "Mape", + "folders_feature_description": "Pregledavanje prikaza mape za fotografije i videozapise u sustavu datoteka", + "force_re-scan_library_files": "Prisilno ponovno skeniraj sve datoteke biblioteke", + "forward": "Naprijed", + "general": "Općenito", + "get_help": "Potražite pomoć", + "getting_started": "Početak Rada", + "go_back": "Idi natrag", + "go_to_search": "Idi na pretragu", "go_to_share_page": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "import_from_json": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", + "group_albums_by": "Grupiraj albume po...", + "group_no": "Nema grupiranja", + "group_owner": "Grupiraj po vlasniku", + "group_year": "Grupiraj po godini", + "has_quota": "Ima kvotu", + "hi_user": "Bok {name} ({email})", + "hide_all_people": "Sakrij sve ljude", + "hide_gallery": "Sakrij galeriju", + "hide_named_person": "Sakrij osobu {name}", + "hide_password": "Sakrij lozinku", + "hide_person": "Sakrij osobu", + "hide_unnamed_people": "Sakrij neimenovane osobe", + "host": "Domaćin", + "hour": "Sat", + "image": "Slika", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} snimljeno {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} i {person2} {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {additionalCount, number} drugih {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} i {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {additionalCount, number} drugih {date}", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich Web Sučelje", + "import_from_json": "Uvoz iz JSON-a", + "import_path": "Putanja uvoza", + "in_albums": "U {count, plural, one {# album} other {# albuma}}", + "in_archive": "U arhivi", + "include_archived": "Uključi arhivirano", + "include_shared_albums": "Uključi dijeljene albume", + "include_shared_partner_assets": "Uključite zajedničku imovinu partnera", + "individual_share": "Pojedinačni udio", + "info": "Informacije", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Svaki dan u 13 sati", + "hours": "{hours, plural, one {Svaki sat} few {Svakih {hours, number} sata} other {Svakih {hours, number} sati}}", + "night_at_midnight": "Svaku večer u ponoć", + "night_at_twoam": "Svake noći u 2 ujutro" }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "note_apply_storage_label_to_previously_uploaded assets": "", - "note_unlimited_quota": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", - "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "only_refreshes_modified_files": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_can_access": "", + "invite_people": "Pozovite ljude", + "invite_to_album": "Pozovi u album", + "items_count": "{count, plural, one {# datoteka} other {# datoteke}}", + "jobs": "Poslovi", + "keep": "Zadrži", + "keep_all": "Zadrži Sve", + "keyboard_shortcuts": "Prečaci tipkovnice", + "language": "Jezik", + "language_setting_description": "Odaberite željeni jezik", + "last_seen": "Zadnji put viđen", + "latest_version": "Najnovija verzija", + "latitude": "Zemljopisna širina", + "leave": "Izađi", + "let_others_respond": "Dozvoli da drugi odgovore", + "level": "Razina", + "library": "Biblioteka", + "library_options": "Mogućnosti biblioteke", + "light": "Svjetlo", + "like_deleted": "Like izbrisan", + "link_motion_video": "Povežite videozapis pokreta", + "link_options": "Opcije veze", + "link_to_oauth": "Veza na OAuth", + "linked_oauth_account": "Povezani OAuth račun", + "list": "Popis", + "loading": "Učitavanje", + "loading_search_results_failed": "Učitavanje rezultata pretraživanja nije uspjelo", + "log_out": "Odjavi se", + "log_out_all_devices": "Odjava sa svih uređaja", + "logged_out_all_devices": "Odjavljeni su svi uređaji", + "logged_out_device": "Odjavljen uređaj", + "login": "Prijava", + "login_has_been_disabled": "Prijava je onemogućena.", + "logout_all_device_confirmation": "Jeste li sigurni da želite odjaviti sve uređaje?", + "logout_this_device_confirmation": "Jeste li sigurni da se želite odjaviti s ovog uređaja?", + "longitude": "Zemljopisna dužina", + "look": "Izgled", + "loop_videos": "Ponavljajte videozapise", + "loop_videos_description": "Omogućite automatsko ponavljanje videozapisa u pregledniku detalja.", + "make": "Proizvođač", + "manage_shared_links": "Upravljanje dijeljenim vezama", + "manage_sharing_with_partners": "Upravljajte dijeljenjem s partnerima", + "manage_the_app_settings": "Upravljajte postavkama aplikacije", + "manage_your_account": "Upravljajte svojim računom", + "manage_your_api_keys": "Upravljajte svojim API ključevima", + "manage_your_devices": "Upravljajte uređajima na kojima ste prijavljeni", + "manage_your_oauth_connection": "Upravljajte svojom OAuth vezom", + "map": "Karta", + "map_marker_for_images": "Oznaka karte za slike snimljene u {city}, {country}", + "map_marker_with_image": "Oznaka karte sa slikom", + "map_settings": "Postavke karte", + "matches": "Podudaranja", + "media_type": "Vrsta medija", + "memories": "Sjećanja", + "memories_setting_description": "Upravljajte onim što vidite u svojim sjećanjima", + "memory": "Memorija", + "memory_lane_title": "Traka sjećanja {title}", + "menu": "Izbornik", + "merge": "Spoji", + "merge_people": "Spajanje ljudi", + "merge_people_limit": "Možete spojiti najviše 5 lica odjednom", + "merge_people_prompt": "Želite li spojiti ove ljude? Ova radnja je nepovratna.", + "merge_people_successfully": "Uspješno spajanje ljudi", + "merged_people_count": "{count, plural, one {# Spojena osoba} other {# Spojene osobe}}", + "minimize": "Minimiziraj", + "minute": "Minuta", + "missing": "Nedostaje", + "model": "Model", + "month": "Mjesec", + "more": "Više", + "moved_to_trash": "Premješteno u smeće", + "my_albums": "Moji albumi", + "name": "Ime", + "name_or_nickname": "Ime ili nadimak", + "never": "Nikada", + "new_album": "Novi Album", + "new_api_key": "Novi API ključ", + "new_password": "Nova lozinka", + "new_person": "Nova osoba", + "new_user_created": "Stvoren novi korisnik", + "new_version_available": "DOSTUPNA NOVA VERZIJA", + "newest_first": "Prvo najnovije", + "next": "Sljedeće", + "next_memory": "Sljedeće sjećanje", + "no": "Ne", + "no_albums_message": "Izradite album za organiziranje svojih fotografija i videozapisa", + "no_albums_with_name_yet": "Čini se da još nemate nijedan album s ovim imenom.", + "no_albums_yet": "Čini se da još nemate nijedan album.", + "no_archived_assets_message": "Arhivirajte fotografije i videozapise kako biste ih sakrili iz prikaza fotografija", + "no_assets_message": "KLIKNITE DA PRENESETE SVOJU PRVU FOTOGRAFIJU", + "no_duplicates_found": "Nisu pronađeni duplikati.", + "no_exif_info_available": "Nema dostupnih exif podataka", + "no_explore_results_message": "Prenesite više fotografija da istražite svoju zbirku.", + "no_favorites_message": "Dodajte favorite kako biste brzo pronašli svoje najbolje slike i videozapise", + "no_libraries_message": "Stvorite vanjsku biblioteku za pregled svojih fotografija i videozapisa", + "no_name": "Bez imena", + "no_places": "Nema mjesta", + "no_results": "Nema rezultata", + "no_results_description": "Pokušajte sa sinonimom ili općenitijom ključnom riječi", + "no_shared_albums_message": "Stvorite album za dijeljenje fotografija i videozapisa s osobama u svojoj mreži", + "not_in_any_album": "Ni u jednom albumu", + "note_apply_storage_label_to_previously_uploaded assets": "Napomena: Da biste primijenili Oznaku za skladištenje na prethodno prenesena sredstva, pokrenite", + "note_unlimited_quota": "napomena: Unesite 0 za neograni%C4%8Denu kvotu", + "notes": "Bilješke", + "notification_toggle_setting_description": "Omogući obavijesti putem e-pošte", + "notifications": "Obavijesti", + "notifications_setting_description": "Upravljanje obavijestima", + "oauth": "OAuth", + "offline": "Izvan mreže", + "offline_paths": "Izvanmrežne putanje", + "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", + "ok": "Ok", + "oldest_first": "Prvo najstarije", + "onboarding": "Uključivanje (Onboarding)", + "onboarding_privacy_description": "Sljedeće (neobavezne) značajke oslanjaju se na vanjske usluge i mogu se onemogućiti u bilo kojem trenutku u postavkama administracije.", + "onboarding_theme_description": "Odaberite temu boja za svoj primjer. To možete kasnije promijeniti u postavkama.", + "onboarding_welcome_description": "Postavimo vašu instancu s nekim uobičajenim postavkama.", + "onboarding_welcome_user": "Dobro došli, {user}", + "online": "Dostupan (Online)", + "only_favorites": "Samo omiljeno", + "only_refreshes_modified_files": "Osvježava samo izmijenjene datoteke", + "open_in_map_view": "Otvori u prikazu karte", + "open_in_openstreetmap": "Otvori u OpenStreetMap", + "open_the_search_filters": "Otvorite filtre pretraživanja", + "options": "Opcije", + "or": "ili", + "organize_your_library": "Organizirajte svoju knjižnicu", + "original": "original", + "other": "Ostalo", + "other_devices": "Ostali uređaji", + "other_variables": "Ostale varijable", + "owned": "Vlasništvo", + "owner": "Vlasnik", + "partner": "Partner", + "partner_can_access": "{partner} može pristupiti", "partner_can_access_assets": "", "partner_can_access_location": "", "partner_sharing": "", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index cbe3651927..daee687003 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Impossibile ottenere il numero di commenti", "unable_to_get_shared_link": "Impossibile ottenere il link condiviso", "unable_to_hide_person": "Impossibile nascondere persona", + "unable_to_link_motion_video": "Impossibile collegare video in movimento", "unable_to_link_oauth_account": "Impossibile collegare l'account OAuth", "unable_to_load_album": "Impossibile caricare l'album", "unable_to_load_asset_activity": "Impossibile caricare l'attività dell'asset", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Impossibile eseguire l'attività", "unable_to_trash_asset": "Impossibile cestinare l'asset", "unable_to_unlink_account": "Impossibile scollegare l'account", + "unable_to_unlink_motion_video": "Impossibile scollegare video in movimento", "unable_to_update_album_cover": "Errore durante l'aggiornamento della copertina dell'album", "unable_to_update_album_info": "Impossibile aggiornare le informazioni sull'album", "unable_to_update_library": "Impossibile aggiornare la libreria", @@ -1290,6 +1292,7 @@ "unknown_album": "Album sconosciuto", "unknown_year": "Anno sconosciuto", "unlimited": "Illimitato", + "unlink_motion_video": "Scollega video in movimento", "unlink_oauth": "Scollega OAuth", "unlinked_oauth_account": "Scollega account OAuth", "unnamed_album": "Album senza nome", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index df46923b5e..8adf988f5d 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -139,7 +139,11 @@ "map_settings_description": "지도 설정 관리", "map_style_description": "지도 테마 style.json URL", "metadata_extraction_job": "메타데이터 추출", - "metadata_extraction_job_description": "각 항목에서 GPS, 해상도 등의 메타데이터 정보 추출", + "metadata_extraction_job_description": "각 항목에서 GPS, 인물 및 해상도 등의 메타데이터 정보 추출", + "metadata_faces_import_setting": "얼굴 가져오기 활성화", + "metadata_faces_import_setting_description": "사이드카 파일의 이미지 EXIF 데이터에서 얼굴 가져오기", + "metadata_settings": "메타데이터 설정", + "metadata_settings_description": "메타데이터 설정 관리", "migration_job": "마이그레이션", "migration_job_description": "각 항목의 섬네일 및 인물의 얼굴을 최신 폴더 구조로 마이그레이션", "no_paths_added": "추가된 경로 없음", @@ -1114,6 +1118,7 @@ "search_for_existing_person": "존재하는 인물 검색", "search_no_people": "인물이 없습니다.", "search_no_people_named": "\"{name}\" 인물을 찾을 수 없음", + "search_options": "검색 옵션", "search_people": "인물 검색", "search_places": "장소 검색", "search_state": "지역 검색...", @@ -1243,6 +1248,7 @@ "to_change_password": "비밀번호 변경", "to_favorite": "즐겨찾기", "to_login": "로그인", + "to_parent": "상위 항목으로", "to_root": "루트", "to_trash": "삭제", "toggle_settings": "설정 변경", diff --git a/web/src/lib/i18n/lv.json b/web/src/lib/i18n/lv.json index bf17ccb813..2701cda4e8 100644 --- a/web/src/lib/i18n/lv.json +++ b/web/src/lib/i18n/lv.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Pievienot koplietotam albumam", "added_to_archive": "Pievienots arhīvam", "added_to_favorites": "Pievienots izlasei", - "added_to_favorites_count": "Pievienots {count} izlasei", + "added_to_favorites_count": "Pievienots {count, number} izlasei", "admin": { "add_exclusion_pattern_description": "Pievienojiet izlaišanas shēmas. Aizstājējzīmju izmantoša *, **, un ? tiek atbalstīta. Lai ignorētu visus failus jebkurā direktorijā ar nosaukumu “RAW”, izmantojiet “**/RAW/**”. Lai ignorētu visus failus, kas beidzas ar “. tif”, izmantojiet “**/*. tif”. Lai ignorētu absolūto ceļu, izmantojiet “/path/to/ignore/**”.", "authentication_settings": "Autentifikācijas iestatījumi", @@ -44,6 +44,9 @@ "disable_login": "Atspējot pieteikšanos", "disabled": "", "duplicate_detection_job_description": "Palaidiet mašīnmācīšanos uz līdzekļiem, lai noteiktu līdzīgus attēlus. Paļaujas uz Viedo Meklēšanu", + "external_library_created_at": "Ārēja bibliotēka (izveidota {date})", + "external_library_management": "Ārējo bibliotēku pārvaldība", + "face_detection": "Seju noteikšana", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -82,7 +85,7 @@ "machine_learning_enabled_description": "", "machine_learning_facial_recognition": "", "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", + "machine_learning_facial_recognition_model": "Seju atpazīšanas modelis", "machine_learning_facial_recognition_model_description": "", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", @@ -102,11 +105,13 @@ "manage_log_settings": "", "map_dark_style": "", "map_enable_description": "", + "map_gps_settings": "Kartes un GPS iestatījumi", + "map_gps_settings_description": "Pārvaldīt karšu un GPS (apgrieztās ģeokodēšanas) iestatījumus", "map_light_style": "", "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Karte", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -151,10 +156,12 @@ "password_enable_description": "", "password_settings": "", "password_settings_description": "", + "quota_size_gib": "Kvotas izmērs (GiB)", + "require_password_change_on_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "server_external_domain_settings": "", "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", + "server_settings": "Servera iestatījumi", + "server_settings_description": "Pārvaldīt servera iestatījumus", "server_welcome_message": "", "server_welcome_message_description": "", "sidecar_job_description": "", @@ -234,38 +241,46 @@ "trash_settings_description": "", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", + "user_management": "Lietotāju pārvaldība", "user_settings": "", "user_settings_description": "", - "version_check_enabled_description": "", + "version_check_enabled_description": "Ieslēgt versijas pārbaudi", + "version_check_implications": "Versiju pārbaudes funkcija ir atkarīga no periodiskas saziņas ar github.com", "version_check_settings": "", "version_check_settings_description": "", "video_conversion_job_description": "" }, - "admin_email": "", - "admin_password": "", - "administration": "", + "admin_email": "Administratora e-pasts", + "admin_password": "Administratora parole", + "administration": "Administrēšana", "advanced": "Papildu", - "album_added": "", + "album_added": "Albums pievienots", "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", + "album_cover_updated": "Albuma attēls atjaunināts", + "album_info_updated": "Albuma informācija atjaunināta", + "album_leave": "Pamest albumu?", + "album_name": "Albuma nosaukums", "album_options": "", - "album_updated": "", + "album_remove_user": "Noņemt lietotāju?", + "album_updated": "Albums atjaunināts", "album_updated_setting_description": "", - "albums": "", + "albums": "Albumi", "all": "Viss", - "all_people": "", + "all_albums": "Visi albumi", + "all_people": "Visi cilvēki", + "all_videos": "Visi video", "allow_dark_mode": "", "allow_edits": "", - "api_key": "", - "api_keys": "", + "api_key": "API atslēga", + "api_keys": "API atslēgas", "app_settings": "", "appears_in": "", "archive": "Arhīvs", "archive_or_unarchive_photo": "", + "archive_size": "Arhīva izmērs", "archived": "", "asset_offline": "", + "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "back": "Atpakaļ", @@ -303,7 +318,7 @@ "comments_are_disabled": "", "confirm": "Apstiprināt", "confirm_admin_password": "", - "confirm_password": "Apstiprināt Paroli", + "confirm_password": "Apstiprināt paroli", "contain": "", "context": "", "continue": "", @@ -324,8 +339,8 @@ "create_link": "Izveidot saiti", "create_link_to_share": "Izveidot kopīgošanas saiti", "create_new_person": "", - "create_new_user": "", - "create_user": "", + "create_new_user": "Izveidot jaunu lietotāju", + "create_user": "Izveidot lietotāju", "created": "", "current_device": "", "custom_locale": "", @@ -344,7 +359,7 @@ "delete_library": "", "delete_link": "", "delete_shared_link": "Dzēst Kopīgošanas saiti", - "delete_user": "", + "delete_user": "Dzēst lietotāju", "deleted_shared_link": "", "description": "Apraksts", "details": "INFORMĀCIJA", @@ -360,6 +375,7 @@ "done": "Gatavs", "download": "Lejupielādēt", "downloading": "", + "duplicates": "Dublikāti", "duration": "", "durations": { "days": "", @@ -382,7 +398,7 @@ "edit_name": "Rediģēt vārdu", "edit_people": "", "edit_title": "", - "edit_user": "", + "edit_user": "Labot lietotāju", "edited": "", "editor": "", "email": "E-pasts", @@ -395,6 +411,7 @@ "error": "", "error_loading_image": "", "errors": { + "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", "unable_to_add_partners": "", @@ -405,10 +422,10 @@ "unable_to_check_items": "", "unable_to_create_admin_account": "", "unable_to_create_library": "", - "unable_to_create_user": "", + "unable_to_create_user": "Neizdevās izveidot lietotāju", "unable_to_delete_album": "", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_user": "Neizdevās dzēst lietotāju", "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", @@ -450,11 +467,11 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exit_slideshow": "", + "exit_slideshow": "Iziet no slīdrādes", "expand_all": "", "expire_after": "Derīguma termiņš beidzas pēc", "expired": "Derīguma termiņš beidzās", - "explore": "", + "explore": "Izpētīt", "extension": "", "external_libraries": "", "failed_to_get_people": "", @@ -471,6 +488,7 @@ "filetype": "", "filter_people": "", "fix_incorrect_match": "", + "folders": "Mapes", "force_re-scan_library_files": "", "forward": "", "general": "", @@ -480,7 +498,7 @@ "go_to_search": "", "go_to_share_page": "", "group_albums_by": "", - "has_quota": "", + "has_quota": "Ir kvota", "hide_gallery": "", "hide_password": "", "hide_person": "", @@ -491,7 +509,7 @@ "immich_logo": "", "import_path": "", "in_archive": "", - "include_archived": "Iekļaut Arhivētos", + "include_archived": "Iekļaut arhivētos", "include_shared_albums": "", "include_shared_partner_assets": "", "individual_share": "", @@ -537,8 +555,9 @@ "manage_your_api_keys": "", "manage_your_devices": "", "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", + "map": "Karte", + "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", + "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", "media_type": "", "memories": "", @@ -559,9 +578,9 @@ "name_or_nickname": "", "never": "nekad", "new_api_key": "", - "new_password": "Jauna Parole", + "new_password": "Jaunā parole", "new_person": "", - "new_user_created": "", + "new_user_created": "Izveidots jauns lietotājs", "newest_first": "", "next": "Nākošais", "next_memory": "", @@ -569,6 +588,7 @@ "no_albums_message": "", "no_archived_assets_message": "", "no_assets_message": "", + "no_duplicates_found": "Dublikāti netika atrasti.", "no_exif_info_available": "", "no_explore_results_message": "", "no_favorites_message": "", @@ -587,8 +607,9 @@ "ok": "", "oldest_first": "", "online": "", - "only_favorites": "", + "only_favorites": "Tikai izlase", "only_refreshes_modified_files": "", + "open_in_openstreetmap": "Atvērt OpenStreetMap", "open_the_search_filters": "", "options": "Iestatījumi", "organize_your_library": "", @@ -658,14 +679,17 @@ "repair_no_results_message": "", "replace_with_upload": "", "require_password": "", + "require_user_to_change_password_on_first_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "reset": "", "reset_password": "", "reset_people_visibility": "", "reset_settings_to_default": "", + "resolve_duplicates": "Atrisināt dublēšanās gadījumus", + "resolved_all_duplicates": "Visi dublikāti ir atrisināti", "restore": "Atjaunot", "restore_user": "", "retry_upload": "", - "review_duplicates": "", + "review_duplicates": "Pārskatīt dublikātus", "role": "", "save": "Saglabāt", "saved_profile": "", @@ -691,8 +715,9 @@ "search_your_photos": "Meklēt Jūsu fotoattēlus", "searching_locales": "", "second": "", - "select_album_cover": "", + "select_album_cover": "Izvēlieties albuma vāciņu", "select_all": "", + "select_all_duplicates": "Atlasīt visus dublikātus", "select_avatar_color": "", "select_face": "", "select_featured_photo": "", @@ -702,7 +727,8 @@ "selected": "", "send_message": "", "server": "", - "server_stats": "", + "server_online": "Serveris tiešsaistē", + "server_stats": "Servera statistika", "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", @@ -715,7 +741,7 @@ "shared": "Kopīgots", "shared_by": "", "shared_by_you": "", - "shared_links": "Kopīgotas Saites", + "shared_links": "Kopīgotās saites", "sharing": "Kopīgošana", "sharing_sidebar_description": "", "show_album_options": "", @@ -735,8 +761,8 @@ "sign_up": "", "size": "", "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", + "slideshow": "Slīdrāde", + "slideshow_settings": "Slīdrādes iestatījumi", "sort_albums_by": "", "stack": "Steks", "stack_selected_photos": "", @@ -746,7 +772,7 @@ "status": "", "stop_motion_photo": "", "stop_photo_sharing": "Beigt kopīgot jūsu fotogrāfijas?", - "storage": "", + "storage": "Uzglabāšanas vieta", "storage_label": "", "submit": "", "suggestions": "Ieteikumi", @@ -762,7 +788,7 @@ "toggle_settings": "", "toggle_theme": "", "toggle_visibility": "", - "total_usage": "", + "total_usage": "Kopējais lietojums", "trash": "Atkritne", "trash_all": "", "trash_no_results_message": "", @@ -774,6 +800,7 @@ "unknown": "", "unknown_album": "", "unknown_year": "", + "unlimited": "Neierobežots", "unlink_oauth": "", "unlinked_oauth_account": "", "unselect_all": "", @@ -782,16 +809,17 @@ "updated_password": "", "upload": "Augšupielādēt", "upload_concurrency": "", + "upload_status_duplicates": "Dublikāti", "upload_status_errors": "Kļūdas", "upload_status_uploaded": "Augšupielādēts", "url": "", - "usage": "", + "usage": "Lietojums", "user": "Lietotājs", "user_id": "Lietotāja ID", - "user_usage_detail": "", + "user_usage_detail": "Informācija par lietotāju lietojumu", "username": "", "users": "Lietotāji", - "utilities": "", + "utilities": "Rīki", "validate": "", "variables": "", "version": "Versija", @@ -804,7 +832,7 @@ "view_next_asset": "", "view_previous_asset": "", "viewer": "", - "waiting": "", + "waiting": "Gaida", "week": "", "welcome_to_immich": "", "year": "", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index 1c0e2f5eef..be2ae2638e 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -138,6 +138,8 @@ "map_style_description": "URL til et style.json-karttema", "metadata_extraction_job": "Hent metadata", "metadata_extraction_job_description": "Hent metadatainformasjon fra hver fil, for eksempel GPS-posisjon og oppløsning", + "metadata_settings": "Metadatainnstillinger", + "metadata_settings_description": "Administrer metadatainnstillinger", "migration_job": "Migrering", "migration_job_description": "Migrer miniatyrbilder for filer og ansikter til den nyeste mappestrukturen", "no_paths_added": "Ingen filbaner lagt til", @@ -384,6 +386,7 @@ "asset_offline": "Fil utilgjengelig", "asset_offline_description": "Dette elementet er offline. Immich kan ikke aksessere dets lokasjon. Vennlist påse at elementet er tilgijengelig og skann så biblioteket på nytt.", "asset_skipped": "Hoppet over", + "asset_skipped_in_trash": "I søppelbøtten", "asset_uploaded": "Lastet opp", "asset_uploading": "Laster opp...", "assets": "Filer", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index 786a1627fe..dc9f003978 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -391,6 +391,7 @@ "asset_offline": "Asset offline", "asset_offline_description": "Deze asset is offline. Immich kan de bestandslocatie niet openen. Controleer of de asset beschikbaar is en scan de bibliotheek opnieuw.", "asset_skipped": "Overgeslagen", + "asset_skipped_in_trash": "In prullenbak", "asset_uploaded": "Geüpload", "asset_uploading": "Uploaden...", "assets": "Assets", @@ -1135,6 +1136,7 @@ "search_for_existing_person": "Zoek naar bestaande persoon", "search_no_people": "Geen mensen", "search_no_people_named": "Geen mensen genaamd \"{name}\"", + "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", "search_state": "Zoek staat...", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 29acdd03ce..02022569cd 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -235,95 +235,127 @@ "storage_template_onboarding_description": "Atunci când este activată, această caracteristică va organiza automat fișierele pe baza unui șablon definit de utilizator. Din cauza unor probleme de stabilitate, aceasta caracteristică este dezactivată implicit. Pentru mai multe informații, te rog sa consulți documentația.", "storage_template_path_length": "Limita de lungime pentru calea aproximativă: {length, number}/{limit, number}", "storage_template_settings": "Șablon stocare", - "storage_template_settings_description": "", + "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", + "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", "system_settings": "Setǎri de sistem", "theme_custom_css_settings": "CSS personalizat", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "thumbnail_generation_job_description": "", + "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", + "theme_settings": "Setări temă", + "theme_settings_description": "Gestionează personalizarea interfeței web Immich", + "these_files_matched_by_checksum": "Aceste fișiere sunt comparate folosind sumele de control", + "thumbnail_generation_job": "Gerează miniaturi", + "thumbnail_generation_job_description": "Generează miniaturi mari, mici și estompate pentru fiecare resursă, precum și miniaturi pentru fiecare persoană", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api": "API de accelerare", + "transcoding_acceleration_api_description": "API-ul care va interacționa cu dispozitivul tău pentru a accelera transcodarea. Această setare este 'best effort': va reveni la transcodarea software în caz de eșec. VP9 poate funcționa sau nu, în funcție de hardware-ul tău.", "transcoding_acceleration_nvenc": "NVENC (necesitǎ GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (necesitǎ CPU Intel de generația a 7-a sau mai mare)", "transcoding_acceleration_rkmpp": "RKMPP (doar pe SOC-uri Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codec-uri audio acceptate", - "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_audio_codecs_description": "Selectează care codec-uri audio nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_accepted_containers": "Containere acceptate", + "transcoding_accepted_containers_description": "Selectează formatele de containere care nu trebuie să fie remuxate în MP4. Se utilizează doar pentru anumite politici de transcodare.", "transcoding_accepted_video_codecs": "Codec-uri video acceptate", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", + "transcoding_accepted_video_codecs_description": "Selectează codec-urile video care nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_advanced_options_description": "Opțiuni pe care majoritatea utilizatorilor nu ar trebui să fie necesar să le schimbe", "transcoding_audio_codec": "Codec audio", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", + "transcoding_audio_codec_description": "Opus este opțiunea cu cea mai bună calitate, dar are o compatibilitate mai scăzută cu dispozitivele sau software-ul mai vechi.", + "transcoding_bitrate_description": "Videoclipuri cu un bitrate mai mare decât maximul acceptat sau care nu sunt într-un format acceptat", + "transcoding_codecs_learn_more": "Pentru a afla mai multe despre terminologia folosită aici, consultă documentația FFmpeg pentru codec-ul H.264, codec-ul HEVC și codec-ul VP9.", + "transcoding_constant_quality_mode": "Mod de calitate constantă", + "transcoding_constant_quality_mode_description": "ICQ este mai bun decât CQP, dar unele dispozitive de accelerare hardware nu suportă acest mod. Setarea acestei opțiuni va prefera modul specificat atunci când folosești codificarea bazată pe calitate. Ignorat de NVENC deoarece nu suportă ICQ.", + "transcoding_constant_rate_factor": "Factor de rată constantă (-crf)", + "transcoding_constant_rate_factor_description": "Nivelul de calitate al videoclipului. Valorile tipice sunt 23 pentru H.264, 28 pentru HEVC, 31 pentru VP9 și 35 pentru AV1. Cu cât valoarea este mai mică, cu atât calitatea este mai bună, dar se generează fișiere mai mari.", + "transcoding_disabled_description": "Nu transcodifică niciun videoclip; acest lucru poate afecta redarea pe anumite dispozitive", + "transcoding_hardware_acceleration": "Accelerare Hardware", + "transcoding_hardware_acceleration_description": "Experimental; mult mai rapid, dar va avea o calitate mai scăzută la același bitrate", + "transcoding_hardware_decoding": "Decodare hardware", + "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", + "transcoding_hevc_codec": "codec HEVC", "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", + "transcoding_max_bitrate": "Bitrate maxim", + "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", + "transcoding_max_keyframe_interval": "Interval maxim între cadre cheie", + "transcoding_max_keyframe_interval_description": "Setează distanța maximă între cadrele cheie. Valorile mai mici reduc eficiența compresiei, dar îmbunătățesc timpii de căutare și pot îmbunătăți calitatea în scenele cu mișcare rapidă. 0 setează această valoare automat.", + "transcoding_optimal_description": "Videoclipuri cu rezoluție mai mare decât cea țintă sau care nu sunt într-un format acceptat", + "transcoding_preferred_hardware_device": "Dispozitiv hardware preferat", + "transcoding_preferred_hardware_device_description": "Se aplică doar la VAAPI și QSV. Setează nodul DRI utilizat pentru transcodarea hardware.", + "transcoding_preset_preset": "Presetare (-preset)", + "transcoding_preset_preset_description": "Viteza de compresie. Presetările mai lente produc fișiere mai mici și îmbunătățesc calitatea atunci când vizezi o anumită rată de biți. VP9 ignoră vitezele de compresie mai mari decât 'mai rapid'.", + "transcoding_reference_frames": "Cadre de referință", + "transcoding_reference_frames_description": "Numărul de cadre de referință atunci când se comprimă un cadru dat. Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. 0 setează această valoare automat.", + "transcoding_required_description": "Numai videoclipuri care nu sunt într-un format acceptat", + "transcoding_settings": "Setări de transcodare video", + "transcoding_settings_description": "Gestionează rezoluția și informațiile de codare ale fișierelor video", + "transcoding_target_resolution": "Rezoluția țintă", + "transcoding_target_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru codare, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", + "transcoding_temporal_aq": "AQ temporal", + "transcoding_temporal_aq_description": "Se aplică doar la NVENC. Îmbunătățește calitatea scenelor cu detalii mari și mișcare redusă. Poate să nu fie compatibil cu dispozitivele mai vechi.", + "transcoding_threads": "Fire", + "transcoding_threads_description": "Valorile mai mari conduc la o codare mai rapidă, dar lasă mai puțin spațiu serverului pentru a procesa alte sarcini în timp ce este activ. Această valoare nu ar trebui să fie mai mare decât numărul de nuclee CPU. Maximizați utilizarea dacă este setat la 0.", + "transcoding_tone_mapping": "Mapare tonuri", + "transcoding_tone_mapping_description": "Încearcă să păstreze aspectul videoclipurilor HDR atunci când sunt convertite în SDR. Fiecare algoritm face compromisuri diferite pentru culoare, detalii și strălucire. Hable păstrează detaliile, Mobius păstrează culoarea, iar Reinhard păstrează strălucirea.", + "transcoding_tone_mapping_npl": "Mapare tonuri NPL", + "transcoding_tone_mapping_npl_description": "Culorile vor fi ajustate pentru a arăta normal pe un ecran cu această strălucire. În mod contraintuitiv, valorile mai mici cresc strălucirea videoclipului și invers, deoarece compensează pentru strălucirea ecranului. 0 setează această valoare automat.", + "transcoding_transcode_policy": "Politica de transcodare", + "transcoding_transcode_policy_description": "Politica pentru când un videoclip ar trebui să fie transcodificat. Videoclipurile HDR vor fi întotdeauna transcodificate (cu excepția cazului în care transcodarea este dezactivată).", + "transcoding_two_pass_encoding": "Codare în două treceri", + "transcoding_two_pass_encoding_setting_description": "Transcodificare în două treceri pentru a produce videoclipuri codificate mai bine. Când rata maximă de biți este activată (necesară pentru a funcționa cu H.264 și HEVC), acest mod utilizează un interval de rată de biți bazat pe rata maximă de biți și ignoră CRF. Pentru VP9, CRF poate fi utilizat dacă rata maximă de biți este dezactivată.", "transcoding_video_codec": "Codec video", "transcoding_video_codec_description": "VP9 are eficiențǎ mare și compatibilitate web, însǎ transcodarea este de duratǎ mai mare. HEVC se comportǎ asemǎnǎtor, însǎ are compatibilitate web mai micǎ. H.264 este foarte compatibil și rapid în transcodare, însǎ genereazǎ fișiere mult mai mari. AV1 este cel mai eficient codec dar nu este compatibil cu dispozitivele mai vechi.", - "trash_enabled_description": "", + "trash_enabled_description": "Activează funcțiile Coș de gunoi", "trash_number_of_days": "Numǎr de zile", "trash_number_of_days_description": "Numǎr de zile pentru pǎstrarea fișierelor în coșul de gunoi pânǎ la ștergerea permanentǎ", "trash_settings": "Setǎri coș de gunoi", "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", + "untracked_files": "Fișiere neurmărite", + "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", + "user_delete_delay_settings": "Întârziere la ștergere", + "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", + "user_delete_immediately": "Contul și resursele utilizatorului {user} vor fi puse în coadă pentru ștergere permanentă imediat.", + "user_delete_immediately_checkbox": "Pune utilizatorul și resursele în coadă pentru ștergere imediată", + "user_management": "Gestionarea Utilizatorilor", + "user_password_has_been_reset": "Parola utilizatorului a fost resetată:", + "user_password_reset_description": "Vă rugăm să furnizați utilizatorului parola temporară și să îi informați că va trebui să o schimbe la următoarea autentificare.", + "user_restore_description": "Contul utilizatorului {user} va fi restaurat.", + "user_restore_scheduled_removal": "Restaurare utilizator - ștergere programată pe {date, date, long}", "user_settings": "Setǎri utilizator", "user_settings_description": "Gestioneazǎ setǎrile utilizatorului", - "version_check_enabled_description": "Activeazǎ verificarea periodicǎ pe GitHub pentru versiuni noi", + "user_successfully_removed": "Utilizatorul {email} a fost eliminat cu succes.", + "version_check_enabled_description": "Activează verificarea versiunii", + "version_check_implications": "Funcția de verificare a versiunii se bazează pe comunicarea periodică cu github.com", "version_check_settings": "Verificare versiune", "version_check_settings_description": "Activeazǎ/dezactiveazǎ notificarea unei noi versiuni", - "video_conversion_job_description": "Transcodeazǎ videoclipurile pentru compatibilitate cu browsere și dispozitive" + "video_conversion_job": "Transcodați videoclipuri", + "video_conversion_job_description": "Transcodați videoclipurile pentru o compatibilitate mai mare cu browserele și dispozitivele" }, "admin_email": "E-mailul administratorului", - "admin_password": "Parola administratorului", + "admin_password": "Parolă administrator", "administration": "Administrare", "advanced": "Avansat", + "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", + "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", "album_cover_updated": "Coperta albumului a fost actualizată", - "album_info_updated": "Informațiile albumului au fost actualizate", - "album_name": "Nume de album", - "album_options": "Opțiuni de album", + "album_delete_confirmation": "Ești sigur că vrei să ștergi albumul {album}?", + "album_delete_confirmation_description": "Dacă acest album este partajat, alți utilizatori nu vor mai putea accesa.", + "album_info_updated": "Informații album actualizate", + "album_leave": "Lăsați albumul?", + "album_leave_confirmation": "Ești sigur că dorești să părăsești {album}?", + "album_name": "Nume album", + "album_options": "Opțiuni album", + "album_remove_user": "Eliminare utilizator?", + "album_remove_user_confirmation": "Ești sigur că dorești eliminarea {user}?", + "album_share_no_users": "Se pare că ai partajat acest album cu toți utilizatorii sau nu ai niciun utilizator cu care să-l partajezi.", "album_updated": "Album actualizat", "album_updated_setting_description": "Primiți o notificare prin e-mail când un album partajat are elemente noi", + "album_user_left": "A părăsit {album}", + "album_user_removed": "{user} eliminat", + "album_with_link_access": "Permite oricui cu link-ul să vadă fotografiile și persoanele din acest album.", "albums": "Albume", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}", "all": "Toate", @@ -334,40 +366,58 @@ "allow_edits": "Permite editări", "allow_public_user_to_download": "Permite utilizatorului public să descarce", "allow_public_user_to_upload": "Permite utilizatorului public să încarce", + "anti_clockwise": "În sens invers acelor de ceasornic", "api_key": "Cheie API", "api_key_description": "Această valoare va fi afișată o singură dată. Vă rugăm să vă asigurați că o copiați înainte de a închide fereastra.", "api_key_empty": "Numele cheii API nu trebuie să fie gol", "api_keys": "Chei API", - "app_settings": "Setări în aplicație", + "app_settings": "Setări Aplicație", "appears_in": "Apare în", "archive": "Arhivă", "archive_or_unarchive_photo": "Arhiveazǎ sau dezarhiveazǎ fotografia", + "archive_size": "Mărime arhivă", + "archive_size_description": "Configurează dimensiunea arhivei pentru descărcări (în GiB)", "archived": "", - "archived_count": "{count, plural, one {S-a arhivat #}, other {S-au arhivat #}}", + "archived_count": "{count, plural, other {Arhivat/e#}}", + "are_these_the_same_person": "Sunt aceștia aceeași persoană?", "are_you_sure_to_do_this": "Sunteți sigur că doriți să faceți acest lucru?", "asset_added_to_album": "Adăugat la album", - "asset_adding_to_album": "Se adauga la album...", + "asset_adding_to_album": "Se adaugă la album...", "asset_description_updated": "Descrierea activelor a fost actualizată", - "asset_filename_is_offline": "Activul {filename} este offline", - "asset_has_unassigned_faces": "Activul are fețe neatribuite", - "asset_hashing": "Hasurare...", + "asset_filename_is_offline": "Resursa {filename} este offline", + "asset_has_unassigned_faces": "Resursa are fețe neatribuite", + "asset_hashing": "Hașurare...", "asset_offline": "Resursă offline", - "asset_offline_description": "Acest activ este offline. Immich nu poate accesa locația fișierului său. Vă rugăm să vă asigurați că activul este disponibil și apoi să efectuați o nouă scanare a bibliotecii.", + "asset_offline_description": "Această resursă este offline. Immich nu poate accesa locația fișierului său. Vă rugăm să vă asigurați că resursa este disponibilă și apoi să efectuați o nouă scanare a bibliotecii.", "asset_skipped": "Sărit", + "asset_skipped_in_trash": "În gunoi", "asset_uploaded": "Încărcat", - "asset_uploading": "Se incărca...", + "asset_uploading": "Se incarcă...", "assets": "Resurse", + "assets_added_count": "Adăugat {count, plural, one {# resursă} other {# resurse}}", + "assets_added_to_album_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în album", + "assets_added_to_name_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în {hasName, select, true {{name}} other {albumul nou}}", + "assets_count": "{count, plural, one {# resursă} other {# resurse}}", + "assets_moved_to_trash_count": "Am mutat {count, plural, one {# resursă} other {# resurse}} în coșul de gunoi", + "assets_permanently_deleted_count": "Șters permanent {count, plural, one {# resursă} other {# resurse}}", + "assets_removed_count": "Eliminat {count, plural, one {# resursă} other {# resurse}}", + "assets_restore_confirmation": "Ești sigur că vrei să restaurezi toate resursele tale din coșul de gunoi? Nu poți anula această acțiune!", + "assets_restored_count": "Restaurat {count, plural, one {# resursă} other {# resurse}}", + "assets_trashed_count": "Mutat în coșul de gunoi {count, plural, one {# resursă} other {# resurse}}", + "assets_were_part_of_album_count": "{count, plural, one {Resursa era} other {Resursele erau}} deja parte din album", "authorized_devices": "Dispozitive autorizate", "back": "Înapoi", "back_close_deselect": "Înapoi, închidere sau deselectare", - "backward": "Invers", + "backward": "În sens invers", "birthdate_saved": "Data nașterii salvată cu succes", "birthdate_set_description": "Data nașterii este utilizată pentru a calcula vârsta acestei persoane la momentul realizării fotografiei.", "blurred_background": "Fundal neclar", "build": "Construiți", - "build_image": "Construiți o imagine", - "bulk_delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți în masă {count, plural, one {# duplicate asset} other {# duplicate assets}}? Acest lucru va păstra cel mai mare activ din fiecare grup și va șterge definitiv toate celelalte duplicate. Nu puteți anula această acțiune!", - "buy": "Cumpără Immich", + "build_image": "Construiți imagine", + "bulk_delete_duplicates_confirmation": "Ești sigur că vrei să ștergi în masă {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va șterge permanent toate celelalte duplicate. Nu poți anula această acțiune!", + "bulk_keep_duplicates_confirmation": "Ești sigur că vrei să păstrezi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va rezolva toate grupurile duplicate fără a șterge nimic.", + "bulk_trash_duplicates_confirmation": "Ești sigur că vrei să muți în coșul de gunoi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va muta în coșul de gunoi toate celelalte duplicate.", + "buy": "Achiziționează Immich", "camera": "Camerǎ", "camera_brand": "Marcǎ cameră", "camera_model": "Model cameră", @@ -381,37 +431,41 @@ "cant_search_people": "", "cant_search_places": "", "change_date": "Schimbă dată", - "change_expiration_time": "Shimbă data expirării", + "change_expiration_time": "Shimbă dată expirare", "change_location": "Schimbă locația", - "change_name": "Schimbă numele", - "change_name_successfully": "Schimbă numele cu succes", - "change_password": "Schimbă parola", - "change_password_description": "Aceasta este fie prima dată când vă conectați la sistem, fie vi s-a solicitat să vă schimbați parola. Vă rugăm să introduceți noua parolă mai jos.", + "change_name": "Schimbă nume", + "change_name_successfully": "Schimbare nume cu succes", + "change_password": "Schimbă Parolă", + "change_password_description": "Aceasta este fie prima dată când te conectezi în sistem, fie s-a făcut o solicitare pentru a schimba parola ta. Te rog să introduci noua parolă mai jos.", "change_your_password": "Schimbă-ți parola", - "changed_visibility_successfully": "Schimbă visibilitate cu succes", - "check_logs": "Verificarea logurilor", - "choose_matching_people_to_merge": "Alegeți persoanele potrivite pentru fuzionare", + "changed_visibility_successfully": "Schimbare vizibilitate cu succes", + "check_all": "Selectează Tot", + "check_logs": "Verifică Jurnale", + "choose_matching_people_to_merge": "Alegeți persoanele care se potrivesc pentru a le fuziona", "city": "Oraș", - "clear": "ȘTERGE", - "clear_all": "Șterge tot", + "clear": "Curăță", + "clear_all": "Curăță tot", + "clear_all_recent_searches": "Curăță toate căutările recente", "clear_message": "Șterge mesajul", - "clear_value": "Valoare clară", + "clear_value": "Șterge valoare", + "clockwise": "În sensul acelor de ceas", "close": "Închide", - "collapse": "Colaps", - "collapse_all": "Închideți pe toate", + "collapse": "Restrânge", + "collapse_all": "Restrânge pe toate", + "color": "Culoare", "color_theme": "Tema de culoare", "comment_deleted": "Comentariu șters", - "comment_options": "Opțiuni de comentariu", - "comments_and_likes": "Comentarii și aprecieri", + "comment_options": "Opțiuni comentariu", + "comments_and_likes": "Comentarii & aprecieri", "comments_are_disabled": "Comentariile sunt dezactivate", "confirm": "Confirmați", "confirm_admin_password": "Confirmați parola de administrator", "confirm_delete_shared_link": "Sunteți sigur că doriți să ștergeți acest link partajat?", "confirm_password": "Confirmați parola", - "contain": "Conține", + "contain": "Încadrează", "context": "Context", "continue": "Continuați", - "copied_image_to_clipboard": "Copiat imaginea în clipboard.", + "copied_image_to_clipboard": "Imaginea copiată în clipboard.", "copied_to_clipboard": "Copiat în clipboard!", "copy_error": "Eroare de copiere", "copy_file_path": "Copiați calea fișierului", @@ -421,64 +475,70 @@ "copy_password": "Copiați parola", "copy_to_clipboard": "Copiere în Clipboard", "country": "Țara", - "cover": "Acoperire", - "covers": "Acoperiri", + "cover": "Umple fereastra", + "covers": "Acoperă", "create": "Creează", "create_album": "Creează album", - "create_library": "Crearea bibliotecii", + "create_library": "Creează bibliotecă", "create_link": "Creează link", "create_link_to_share": "Creează link pentru a distribui", "create_link_to_share_description": "Permiteți oricui are link-ul să vadă fotografia (fotografiile) selectată(e)", "create_new_person": "Creați o persoană nouă", - "create_new_person_hint": "Atribuiți activele selectate unei persoane noi", - "create_new_user": "Crearea unui nou utilizator", + "create_new_person_hint": "Atribuiți resursele selectate unei persoane noi", + "create_new_user": "Creează utilizator nou", + "create_tag": "Creează etichetă", + "create_tag_description": "Creează o etichetă nouă. Pentru etichete imbricate, te rog să introduci calea completă a etichetei, inclusiv bare oblice (/).", "create_user": "Creează utilizator", "created": "Creat", "current_device": "Dispozitiv curent", - "custom_locale": "Local personalizat", + "custom_locale": "Setare regională personalizată", "custom_locale_description": "Formatați datele și numerele în funcție de limbă și regiune", - "dark": "Întuneric", - "date_after": "Data după", + "dark": "Întunecat", + "date_after": "Dată după", "date_and_time": "Dată și Oră", - "date_before": "Data anterioară", + "date_before": "Dată anterioară", "date_of_birth_saved": "Data nașterii salvată cu succes", "date_range": "Interval de date", - "day": "Ziua", + "day": "Zi", "deduplicate_all": "Deduplicați toate", - "default_locale": "Local implicit", - "default_locale_description": "Formatați datele și numerele în funcție de locația browserului dvs.", + "default_locale": "Setare regionlă implicită", + "default_locale_description": "Formatați datele și numerele în funcție de regiunea browserului dvs", "delete": "Șterge", "delete_album": "Șterge album", "delete_api_key_prompt": "Sunteți sigur că doriți să ștergeți această cheie API?", "delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți permanent aceste duplicate?", - "delete_key": "Tasta de ștergere", - "delete_library": "Ștergeți biblioteca", - "delete_link": "Ștergeți linkul", - "delete_shared_link": "Ștergeți link-ul partajat", - "delete_user": "Ștergeți utilizatorul", + "delete_key": "Șterge cheie", + "delete_library": "Șterge biblioteca", + "delete_link": "Șterge linkul", + "delete_shared_link": "Șterge link-ul partajat", + "delete_tag": "Șterge etichetă", + "delete_tag_confirmation_prompt": "Ești sigur că vrei să ștergi eticheta {tagName} ?", + "delete_user": "Șterge utilizator", "deleted_shared_link": "Link partajat șters", "description": "Descriere", - "details": "DETALII", + "details": "Detalii", "direction": "Direcție", "disabled": "Dezactivat", - "disallow_edits": "Interziceți editările", + "disallow_edits": "Interzice modificările", "discover": "Descoperiți", - "dismiss_all_errors": "Eliminați toate erorile", - "dismiss_error": "Anulați eroarea", + "dismiss_all_errors": "Ignoră toate erorile", + "dismiss_error": "Ignorați eroarea", "display_options": "Opțiuni de afișare", "display_order": "Ordine de afișare", "display_original_photos": "Afișați fotografiile originale", - "display_original_photos_setting_description": "Preferați să afișați fotografia originală atunci când vizualizați un bun în loc de miniaturi atunci când bunul original este compatibil cu web. Acest lucru poate duce la o viteză mai mică de afișare a fotografiilor.", - "do_not_show_again": "Nu mai afișați acest mesaj", + "display_original_photos_setting_description": "Preferă să afișezi fotografia originală atunci când vizualizezi o resursă, în loc de miniaturi, atunci când resursa originală este compatibilă cu web-ul. Aceasta poate duce la viteze mai lente de afișare a fotografiilor.", + "do_not_show_again": "Nu mai afișa acest mesaj", "done": "Gata", "download": "Descarcă", + "download_include_embedded_motion_videos": "Videoclipuri încorporate", + "download_include_embedded_motion_videos_description": "Include videoclipurile încorporate în fotografiile în mișcare ca fișier separat", "download_settings": "Descarcă", - "download_settings_description": "Gestionați setările legate de descărcarea activelor", - "downloading": "Descărcare", - "downloading_asset_filename": "Descărcarea activului {filename}", - "drop_files_to_upload": "Aruncați fișiere oriunde pentru a le încărca", + "download_settings_description": "Gestionați setările legate de descărcarea resurselor", + "downloading": "Se descarcă", + "downloading_asset_filename": "Se descarcă resursa {filename}", + "drop_files_to_upload": "Trage fișierele aici pentru a le încărca", "duplicates": "Duplicate", - "duplicates_description": "Rezolvați fiecare grup indicând care, dacă există, sunt duplicate", + "duplicates_description": "Rezolvați fiecare grup indicând care sunt duplicate, dacă există", "duration": "Durată", "durations": { "days": "", @@ -487,13 +547,13 @@ "months": "", "years": "" }, - "edit": "Editare", - "edit_album": "Editare album", - "edit_avatar": "Editare avatar", - "edit_date": "Editează data", - "edit_date_and_time": "Editarea datei și orei", + "edit": "Modifică", + "edit_album": "Modificare album", + "edit_avatar": "Modificare avatar", + "edit_date": "Modifică data", + "edit_date_and_time": "Modifică data și ora", "edit_exclusion_pattern": "Editarea modelului de excludere", - "edit_faces": "Editează fețele", + "edit_faces": "Modifică fețele", "edit_import_path": "Editarea căii de import", "edit_import_paths": "Editarea căilor de import", "edit_key": "Tastă de editare", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index bd725a11cf..44b9e48f95 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1,7 +1,7 @@ { "about": "О продукте", "account": "Учётная запись", - "account_settings": "Настройки учётной записи", + "account_settings": "Настройки аккаунта", "acknowledge": "Подтвердить", "action": "Действие", "actions": "Действия", @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Не удалось получить количество комментариев", "unable_to_get_shared_link": "Не удалось получить общую ссылку", "unable_to_hide_person": "Невозможно скрыть персону", + "unable_to_link_motion_video": "Не удается связать движущееся видео", "unable_to_link_oauth_account": "Не удается связать учетную запись OAuth", "unable_to_load_album": "Невозможно загрузить альбом", "unable_to_load_asset_activity": "Не удалось загрузить активность объекта", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Невозможно отправить задание", "unable_to_trash_asset": "Невозможно удалить актив", "unable_to_unlink_account": "Не удалось отсоединить учетную запись", + "unable_to_unlink_motion_video": "Не удается отсоединить движущееся видео", "unable_to_update_album_cover": "Невозможно обновить обложку альбома", "unable_to_update_album_info": "Невозможно обновить информацию об альбоме", "unable_to_update_library": "Не удалось обновить библиотеку", @@ -1291,6 +1293,7 @@ "unknown_album": "Неизвестный альбом", "unknown_year": "Неизвестный Год", "unlimited": "Не ограничено", + "unlink_motion_video": "Отсоединить движущееся видео", "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", "unnamed_album": "Альбом без названия", diff --git a/web/src/lib/i18n/sk.json b/web/src/lib/i18n/sk.json index d6c066a4cc..cd164a0ccf 100644 --- a/web/src/lib/i18n/sk.json +++ b/web/src/lib/i18n/sk.json @@ -112,7 +112,7 @@ "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Mapa", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -460,7 +460,7 @@ "expand_all": "", "expire_after": "Expiruje po", "expired": "Vypršalo", - "explore": "", + "explore": "Preskúmať", "extension": "", "external_libraries": "", "failed_to_get_people": "", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 7bcd1e3dd8..1241ad72fe 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Није могуће добити број коментара", "unable_to_get_shared_link": "Преузимање дељене везе није успело", "unable_to_hide_person": "Није могуће сакрити особу", + "unable_to_link_motion_video": "Није могуће повезати (link) видео снимак", "unable_to_link_oauth_account": "Није могуће повезати OAuth налог", "unable_to_load_album": "Није могуће учитати албум", "unable_to_load_asset_activity": "Није могуће учитати активност средстава", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Није могуће предати задатак", "unable_to_trash_asset": "Није могуће избацити материјал у отпад", "unable_to_unlink_account": "Није могуће раскинути профил", + "unable_to_unlink_motion_video": "Није могуће прекинути везу са видео снимком", "unable_to_update_album_cover": "Није могуће ажурирати насловницу албума", "unable_to_update_album_info": "Није могуће ажурирати информације о албуму", "unable_to_update_library": "Није могуће ажурирати библиотеку", @@ -1291,6 +1293,7 @@ "unknown_album": "Nepoznat Album", "unknown_year": "Непозната Година", "unlimited": "Неограничено", + "unlink_motion_video": "Прекините везу са видео снимком", "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", "unnamed_album": "Неименовани албум", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index beb2009b4d..26f5483c69 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", "unable_to_get_shared_link": "Preuzimanje deljene veze nije uspelo", "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati video sa slikom", "unable_to_link_oauth_account": "Nije moguće povezati OAuth nalog", "unable_to_load_album": "Nije moguće učitati album", "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstava", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", + "unable_to_unlink_motion_video": "Nije moguće odvezati video sa slikom", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -1291,6 +1293,7 @@ "unknown_album": "Nepoznat Album", "unknown_year": "Nepoznata Godina", "unlimited": "Neograničeno", + "unlink_motion_video": "Odveži video od slike", "unlink_oauth": "Prekini vezu sa Oauth-om", "unlinked_oauth_account": "Opozvana veza OAuth naloga", "unnamed_album": "Neimenovani album", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index 32336bfb4e..f34fab2a1e 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -25,7 +25,7 @@ "add_to_shared_album": "เพิ่มเข้าอัลบั้มที่แชร์", "added_to_archive": "เพิ่มเข้าที่เก็บถาวร", "added_to_favorites": "เพิ่มเข้ารายการโปรด", - "added_to_favorites_count": "{count} รูปถูกเพิ่มเข้ารายการโปรด", + "added_to_favorites_count": "{count, number} รูปถูกเพิ่มเข้ารายการโปรด", "admin": { "add_exclusion_pattern_description": "เพิ่มรูปแบบการยกเว้น การ Glob โดยใช้ *, ** และ ? ถูกรองรับ ถ้าต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีใดๆที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", "authentication_settings": "ตั้งค่าการเข้าถึง", @@ -129,16 +129,21 @@ "map_enable_description": "เปิดใช้งานแผนที่", "map_gps_settings": "การตั้งค่าแผนที่และ GPS", "map_gps_settings_description": "จัดการการตั้งค่าแผนที่และ GPS (Reverse Geocoding)", + "map_implications": "ฟีเจอร์แผนที่ต้องการบริการแผ่นแผนที่จากภายนอก (tiles.immich.cloud)", "map_light_style": "แบบสว่าง", "map_manage_reverse_geocoding_settings": "จัดการการตั้งค่าแปลงพิกัดภูมิศาสตร์ ", "map_reverse_geocoding": "ประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_enable_description": "เปิดใช้งานประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_settings": "การตั้งค่าประมวลผลชื่อทางภูมิศาสตร์", - "map_settings": "การตั้งค่าแผนที่", + "map_settings": "แผนที่", "map_settings_description": "จัดการการตั้งค่าแผนที่", "map_style_description": "URL ไปยังธีมแผนที่ style.json", "metadata_extraction_job": "ดึงข้อมูล metadata", - "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความละเอียด", + "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS ใบหน้าและความละเอียด", + "metadata_faces_import_setting": "เปิดการนำเข้าข้อมูลใบหน้า", + "metadata_faces_import_setting_description": "นำเข้าข้อมูลใบหน้าจาก EXIF ของไฟล์ภาพและไฟล์ประกอบ", + "metadata_settings": "การตั้งค่า Metadata", + "metadata_settings_description": "จัดการการตั้งค่า Metadata", "migration_job": "การโยกย้าย", "migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด", "no_paths_added": "ไม่ได้เพิ่มพาธ", @@ -173,7 +178,9 @@ "oauth_issuer_url": "ผู้ออก URL", "oauth_mobile_redirect_uri": "URI เปลี่ยนเส้นทางบนโทรศัพท์", "oauth_mobile_redirect_uri_override": "แทนที่ URI เปลี่ยนเส้นทางบนโทรศัพท์", - "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ 'app.immich:/' เป็น URI เปลี่ยนเส้นทางที่ไม่ถูกต้อง", + "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ OAuth ไม่รองรับ URI บนอุปกรณ์ เช่น '{callback}'", + "oauth_profile_signing_algorithm": "อัลกอริทึมการรับรองบัญชีผู้ใช้", + "oauth_profile_signing_algorithm_description": "อัลกอริทึมใช้ในการรับรองบัญชีผู้ใช้", "oauth_scope": "ขอบเขต", "oauth_settings": "OAuth", "oauth_settings_description": "จัดการการตั้งค่าล็อกอินผ่าน OAuth", @@ -818,7 +825,7 @@ "status": "สถานะ", "stop_motion_photo": "", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", - "storage": "ที่จัดเก็บ", + "storage": "พื้นที่จัดเก็บ", "storage_label": "", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index ce72fde8b4..3e24ccacc4 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -660,6 +660,7 @@ "unable_to_get_comments_number": "Не вдалося отримати кількість коментарів", "unable_to_get_shared_link": "Не вдалося отримати спільне посилання", "unable_to_hide_person": "Неможливо приховати людину", + "unable_to_link_motion_video": "Не вдається зв'язати рухоме відео", "unable_to_link_oauth_account": "Не вдається прив'язати обліковий запис OAuth", "unable_to_load_album": "Неможливо завантажити альбом", "unable_to_load_asset_activity": "Неможливо завантажити активність активу", @@ -700,6 +701,7 @@ "unable_to_submit_job": "Не вдалося відправити завдання", "unable_to_trash_asset": "Неможливо вилучити актив", "unable_to_unlink_account": "Не вдається відв'язати обліковий запис", + "unable_to_unlink_motion_video": "Не вдається від'єднати рухоме відео", "unable_to_update_album_cover": "Неможливо оновити обкладинку альбому", "unable_to_update_album_info": "Неможливо оновити інформацію про альбом", "unable_to_update_library": "Не вдалося оновити бібліотеку", @@ -845,6 +847,7 @@ "license_trial_info_4": "Будь ласка, розгляньте можливість придбання ліцензії для підтримки подальшого розвитку сервісу", "light": "Світла", "like_deleted": "Лайк видалено", + "link_motion_video": "Посилання на рухоме відео", "link_options": "Налаштування посилання", "link_to_oauth": "Приєднання до OAuth", "linked_oauth_account": "Приєднаний акаунт OAuth", @@ -1288,6 +1291,7 @@ "unknown_album": "", "unknown_year": "Невідомий рік", "unlimited": "Без обмежень", + "unlink_motion_video": "Від'єднати рухоме відео", "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", "unnamed_album": "Альбом без назви", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index e94eb7a464..ec8c8d4e7f 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -660,6 +660,7 @@ "unable_to_get_comments_number": "Không thể lấy số lượng bình luận", "unable_to_get_shared_link": "Không thể lấy liên kết chia sẻ", "unable_to_hide_person": "Không thể ẩn người", + "unable_to_link_motion_video": "Không thể liên kết video chuyển động", "unable_to_link_oauth_account": "Không thể liên kết tài khoản OAuth", "unable_to_load_album": "Không thể tải album", "unable_to_load_asset_activity": "Không thể tải hoạt động của ảnh", @@ -700,6 +701,7 @@ "unable_to_submit_job": "Không thể gửi tác vụ", "unable_to_trash_asset": "Không thể chuyển ảnh vào thùng rác", "unable_to_unlink_account": "Không thể hủy liên kết tài khoản", + "unable_to_unlink_motion_video": "Không thể hủy liên kết video chuyển động", "unable_to_update_album_cover": "Không thể cập nhật ảnh bìa album", "unable_to_update_album_info": "Không thể cập nhật thông tin album", "unable_to_update_library": "Không thể cập nhật thư viện", @@ -1261,6 +1263,7 @@ "unknown_album": "", "unknown_year": "Năm không xác định", "unlimited": "Không giới hạn", + "unlink_motion_video": "Hủy liên kết video chuyển động", "unlink_oauth": "Huỷ liên kết OAuth", "unlinked_oauth_account": "Đã huỷ liên kết tài khoản OAuth", "unnamed_album": "Album chưa đặt tên", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index e879365f41..08c236dcbf 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -27,7 +27,7 @@ "added_to_favorites": "添加到收藏", "added_to_favorites_count": "添加{count, number}项到收藏", "admin": { - "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略名为 “Raw” 的任何目录中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", + "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略任何名为 “Raw” 的文件夹中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", "authentication_settings": "认证设置", "authentication_settings_description": "管理密码、OAuth 和其它认证设置", "authentication_settings_disable_all": "确定要禁用所有的登录方式?此操作将完全禁止登录。", @@ -119,7 +119,7 @@ "machine_learning_settings": "机器学习设置", "machine_learning_settings_description": "管理机器学习功能和设置", "machine_learning_smart_search": "智能搜索", - "machine_learning_smart_search_description": "使用CLIP相似度进行图像语义搜索", + "machine_learning_smart_search_description": "使用CLIP以文搜图、智能搜图", "machine_learning_smart_search_enabled": "启用智能搜索", "machine_learning_smart_search_enabled_description": "如果禁用,则不会对图像编码以用于智能搜索。", "machine_learning_url_description": "机器学习服务器的URL", @@ -152,8 +152,8 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", - "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", + "notification_email_from_address_description": "发件人邮箱地址,例如“张三<12345@qq.com>”", + "notification_email_host_description": "服务器地址:(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", "notification_email_password_description": "与邮件服务器进行身份验证时使用的密码", @@ -171,7 +171,7 @@ "oauth_auto_launch_description": "在登录页面自动启动OAuth登录", "oauth_auto_register": "自动注册", "oauth_auto_register_description": "使用OAuth登录后自动注册新用户", - "oauth_button_text": "按钮文本", + "oauth_button_text": "按钮名称", "oauth_client_id": "客户端ID", "oauth_client_secret": "客户端密钥", "oauth_enable_description": "使用OAuth登录", @@ -320,7 +320,7 @@ "user_management": "用户管理", "user_password_has_been_reset": "该用户的密码被重置:", "user_password_reset_description": "请向用户提供临时密码,并告知他们下次登录时需要更改密码。", - "user_restore_description": "{user}的账户将被恢复。", + "user_restore_description": "账户“{user}”将被恢复。", "user_restore_scheduled_removal": "恢复用户 - 计划于{date, date, long}删除", "user_settings": "用户设置", "user_settings_description": "管理用户设置", @@ -465,7 +465,7 @@ "confirm_delete_shared_link": "您确定要删除此共享链接吗?", "confirm_password": "确认密码", "contain": "包含", - "context": "图像语义搜索", + "context": "以文搜图", "continue": "继续", "copied_image_to_clipboard": "已复制图片至剪贴板。", "copied_to_clipboard": "已复制到剪切板!", @@ -496,12 +496,12 @@ "custom_locale": "自定义地区", "custom_locale_description": "日期和数字显示格式跟随语言和地区", "dark": "深色", - "date_after": "日期之后", + "date_after": "开始日期", "date_and_time": "日期与时间", - "date_before": "日期之前", + "date_before": "结束日期", "date_of_birth_saved": "出生日期保存成功", "date_range": "日期范围", - "day": "天", + "day": "日", "deduplicate_all": "删除所有重复项", "default_locale": "默认地区", "default_locale_description": "根据您的浏览器地区设置日期和数字显示格式", @@ -681,7 +681,7 @@ "unable_to_remove_library": "无法移除图库", "unable_to_remove_offline_files": "无法移除离线文件", "unable_to_remove_partner": "无法移除同伴", - "unable_to_remove_reaction": "无法移除反应", + "unable_to_remove_reaction": "无法移除回应", "unable_to_remove_user": "无法移除用户", "unable_to_repair_items": "无法修复项目", "unable_to_reset_password": "无法重置密码", @@ -761,7 +761,7 @@ "group_no": "未分组", "group_owner": "按所有者分组", "group_year": "按年分组", - "has_quota": "有限额", + "has_quota": "配额大小", "hi_user": "你好,{name}({email})", "hide_all_people": "隐藏所有人物", "hide_gallery": "隐藏相册", @@ -769,7 +769,7 @@ "hide_password": "隐藏密码", "hide_person": "隐藏人物", "hide_unnamed_people": "隐藏未命名的人物", - "host": "主机", + "host": "服务器", "hour": "时", "image": "图片", "image_alt_text_date": "在{date}拍摄的{isVideo, select, true {视频} other {照片}}", @@ -793,7 +793,7 @@ "in_albums": "在{count, plural, one {#个相册} other {#个相册}}中", "in_archive": "在归档中", "include_archived": "包括已归档", - "include_shared_albums": "包含共享相册", + "include_shared_albums": "包括共享相册", "include_shared_partner_assets": "包括同伴共享项目", "individual_share": "个人分享", "info": "信息", @@ -930,7 +930,7 @@ "no_shared_albums_message": "创建相册以共享照片和视频", "not_in_any_album": "不在任何相册中", "note_apply_storage_label_to_previously_uploaded assets": "提示:要将存储标签应用于之前上传的项目,运行以下命令", - "note_unlimited_quota": "注:输入 0 表示无限制配额", + "note_unlimited_quota": "注:输入 0 表示无限配额", "notes": "提示", "notification_toggle_setting_description": "启用邮件通知", "notifications": "通知", @@ -1060,7 +1060,7 @@ "rating_count": "{count, plural, one {#星} other {#星}}", "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", - "reaction_options": "反应选项", + "reaction_options": "回应选项", "read_changelog": "阅读更新日志", "reassign": "重新指派", "reassigned_assets_to_existing_person": "重新指派{count, plural, one {#个项目} other {#个项目}}到{name, select, null {已存在的人物} other {{name}}}", @@ -1081,7 +1081,7 @@ "remove_assets_album_confirmation": "确定要从项目中移除{count, plural, one {#个项目} other {#个项目}}?", "remove_assets_shared_link_confirmation": "确定要从共享链接中移除{count, plural, one {#个项目} other {#个项目}}?", "remove_assets_title": "移除项目?", - "remove_custom_date_range": "根据自定义日期范围移除", + "remove_custom_date_range": "取消自定义日期范围", "remove_from_album": "从相册中移除", "remove_from_favorites": "移出收藏", "remove_from_shared_link": "从共享链接中移除", @@ -1181,16 +1181,16 @@ "share": "共享", "shared": "共享", "shared_by": "共享自", - "shared_by_user": "由{user}共享", + "shared_by_user": "由“{user}”共享", "shared_by_you": "你的共享", - "shared_from_partner": "来自{partner}的照片", + "shared_from_partner": "来自“{partner}”的照片", "shared_link_options": "共享链接选项", "shared_links": "共享链接", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", - "shared_with_partner": "与{partner}共享", + "shared_with_partner": "与“{partner}”共享", "sharing": "共享", "sharing_enter_password": "请输入密码后查看此页面。", - "sharing_sidebar_description": "在侧边栏中显示共享链接", + "sharing_sidebar_description": "在侧边栏中显示“共享”链接", "shift_to_permanent_delete": "按住Shift键永久删除项目", "show_album_options": "显示相册选项", "show_albums": "显示相册", @@ -1216,9 +1216,9 @@ "sign_out": "注销", "sign_up": "注册", "size": "大小", - "skip_to_content": "跳到内容", - "skip_to_folders": "跳到文件夹", - "skip_to_tags": "跳到标签", + "skip_to_content": "跳转到内容", + "skip_to_folders": "跳转到文件夹", + "skip_to_tags": "跳转到标签", "slideshow": "幻灯片放映", "slideshow_settings": "放映设置", "sort_albums_by": "相册排序依据...", @@ -1241,15 +1241,15 @@ "status": "状态", "stop_motion_photo": "定格照片", "stop_photo_sharing": "停止共享照片?", - "stop_photo_sharing_description": "{partner}将不能访问你的照片。", + "stop_photo_sharing_description": "“{partner}”将不能访问你的照片。", "stop_sharing_photos_with_user": "停止与此用户共享照片", "storage": "存储空间", "storage_label": "存储标签", - "storage_usage": "总量:{available},已用{used}", + "storage_usage": "总量:{available}/已用:{used}", "submit": "提交", "suggestions": "建议", "sunrise_on_the_beach": "海滩上的日出", - "swap_merge_direction": "交换合并方向", + "swap_merge_direction": "互换合并方向", "sync": "同步", "tag": "标签", "tag_assets": "标记项目", @@ -1262,7 +1262,7 @@ "template": "模版", "theme": "主题", "theme_selection": "主题选项", - "theme_selection_description": "根据浏览器的系统首选项自动设置主题色", + "theme_selection_description": "跟随浏览器自动设置主题颜色", "they_will_be_merged_together": "项目将会合并到一起", "time_based_memories": "基于时间的回忆", "timezone": "时区", @@ -1319,15 +1319,15 @@ "upload_success": "上传成功,刷新页面查看新上传的项目。", "url": "URL", "usage": "用量", - "use_custom_date_range": "使用自定义日期范围", + "use_custom_date_range": "自定义日期范围", "user": "用户", "user_id": "用户ID", "user_license_settings": "授权", "user_license_settings_description": "管理你的授权", - "user_liked": "{user}点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", + "user_liked": "“{user}”点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", "user_purchase_settings": "购买", "user_purchase_settings_description": "管理购买订单", - "user_role_set": "设置{user}为{role}", + "user_role_set": "设置“{user}”为“{role}”", "user_usage_detail": "用户用量详情", "username": "用户名", "users": "用户", @@ -1336,7 +1336,7 @@ "variables": "变量", "version": "版本", "version_announcement_closing": "你的朋友,Alex", - "version_announcement_message": "嗨,伙计,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在错误配置,特别是当你是使用WatchTower或其它类似的自动升级工具时。", + "version_announcement_message": "嗨,朋友,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在配置错误,特别是当你是使用WatchTower或其它类似的自动升级工具时。", "video": "视频", "video_hover_setting": "鼠标悬停时播放视频缩略图", "video_hover_setting_description": "当鼠标悬停在项目上时播放视频缩略图。即使禁用了这个功能,也可以通过将鼠标悬停在播放图标上来开始播放。", @@ -1353,7 +1353,7 @@ "view_stack": "查看堆叠项目", "viewer": "预览", "visibility_changed": "{count, plural, one {#个人物} other {#个人物}}的可见性已修改", - "waiting": "队列中", + "waiting": "准备处理", "warning": "警告", "week": "周", "welcome": "欢迎", From 4a1ff6abce9a94e0f7d0921922edeae9879de5d7 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 16 Sep 2024 22:26:14 +0200 Subject: [PATCH 003/599] refactor(mobile): repositories for album service (#12701) * refactor(mobile): repositories for album service * review feedback, first service unit test --- mobile/lib/entities/album.entity.dart | 3 +- mobile/lib/interfaces/album.interface.dart | 21 +++ mobile/lib/interfaces/asset.interface.dart | 8 ++ mobile/lib/interfaces/backup.interface.dart | 5 + mobile/lib/interfaces/user.interface.dart | 5 + mobile/lib/repositories/album.repository.dart | 85 ++++++++++++ mobile/lib/repositories/asset.repository.dart | 31 +++++ .../lib/repositories/backup.repository.dart | 20 +++ mobile/lib/repositories/user.repository.dart | 20 +++ mobile/lib/services/album.service.dart | 126 ++++++++---------- mobile/lib/services/background.service.dart | 19 ++- mobile/test/repository.mocks.dart | 13 ++ mobile/test/service.mocks.dart | 10 ++ mobile/test/services/album.service.test.dart | 52 ++++++++ 14 files changed, 347 insertions(+), 71 deletions(-) create mode 100644 mobile/lib/interfaces/album.interface.dart create mode 100644 mobile/lib/interfaces/asset.interface.dart create mode 100644 mobile/lib/interfaces/backup.interface.dart create mode 100644 mobile/lib/interfaces/user.interface.dart create mode 100644 mobile/lib/repositories/album.repository.dart create mode 100644 mobile/lib/repositories/asset.repository.dart create mode 100644 mobile/lib/repositories/backup.repository.dart create mode 100644 mobile/lib/repositories/user.repository.dart create mode 100644 mobile/test/repository.mocks.dart create mode 100644 mobile/test/service.mocks.dart create mode 100644 mobile/test/services/album.service.test.dart diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index c05b849dcd..b20cec97c3 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -164,12 +164,13 @@ class Album { } extension AssetsHelper on IsarCollection { - Future store(Album a) async { + Future store(Album a) async { await put(a); await a.owner.save(); await a.thumbnail.save(); await a.sharedUsers.save(); await a.assets.save(); + return a; } } diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart new file mode 100644 index 0000000000..c2ba650b6f --- /dev/null +++ b/mobile/lib/interfaces/album.interface.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAlbumRepository { + Future count({bool? local}); + Future create(Album album); + Future getById(int id); + Future getByName( + String name, { + bool? shared, + bool? remote, + }); + Future update(Album album); + Future delete(int albumId); + Future> getAll({bool? shared}); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart new file mode 100644 index 0000000000..46425ba617 --- /dev/null +++ b/mobile/lib/interfaces/asset.interface.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAssetRepository { + Future> getByAlbum(Album album, {User? notOwnedBy}); + Future deleteById(List ids); +} diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart new file mode 100644 index 0000000000..e343a9d390 --- /dev/null +++ b/mobile/lib/interfaces/backup.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/backup_album.entity.dart'; + +abstract interface class IBackupRepository { + Future> getIdsBySelection(BackupSelection backup); +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart new file mode 100644 index 0000000000..d9841a1187 --- /dev/null +++ b/mobile/lib/interfaces/user.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserRepository { + Future> getByIds(List ids); +} diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart new file mode 100644 index 0000000000..08c939aa6c --- /dev/null +++ b/mobile/lib/repositories/album.repository.dart @@ -0,0 +1,85 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final albumRepositoryProvider = + Provider((ref) => AlbumRepository(ref.watch(dbProvider))); + +class AlbumRepository implements IAlbumRepository { + final Isar _db; + + AlbumRepository( + this._db, + ); + + @override + Future count({bool? local}) { + if (local == true) return _db.albums.where().localIdIsNotNull().count(); + if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); + return _db.albums.count(); + } + + @override + Future create(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future getByName(String name, {bool? shared, bool? remote}) { + var query = _db.albums.filter().nameEqualTo(name); + if (shared != null) { + query = query.sharedEqualTo(shared); + } + if (remote == true) { + query = query.localIdIsNull(); + } else if (remote == false) { + query = query.remoteIdIsNull(); + } + return query.findFirst(); + } + + @override + Future update(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future delete(int albumId) => + _db.writeTxn(() => _db.albums.delete(albumId)); + + @override + Future> getAll({bool? shared}) { + final baseQuery = _db.albums.filter(); + QueryBuilder? query; + if (shared != null) { + query = baseQuery.sharedEqualTo(true); + } + return query?.findAll() ?? _db.albums.where().findAll(); + } + + @override + Future getById(int id) => _db.albums.get(id); + + @override + Future removeUsers(Album album, List users) => + _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + + @override + Future addAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(link: assets)); + + @override + Future removeAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(unlink: assets)); + + @override + Future recalculateMetadata(Album album) async { + album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); + album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); + album.lastModifiedAssetTimestamp = + await album.assets.filter().updatedAtProperty().max(); + return album; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart new file mode 100644 index 0000000000..ea05feab38 --- /dev/null +++ b/mobile/lib/repositories/asset.repository.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final assetRepositoryProvider = + Provider((ref) => AssetRepository(ref.watch(dbProvider))); + +class AssetRepository implements IAssetRepository { + final Isar _db; + + AssetRepository( + this._db, + ); + + @override + Future> getByAlbum(Album album, {User? notOwnedBy}) { + var query = album.assets.filter(); + if (notOwnedBy != null) { + query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + } + return query.findAll(); + } + + @override + Future deleteById(List ids) => + _db.writeTxn(() => _db.assets.deleteAll(ids)); +} diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart new file mode 100644 index 0000000000..c9d93f7877 --- /dev/null +++ b/mobile/lib/repositories/backup.repository.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final backupRepositoryProvider = + Provider((ref) => BackupRepository(ref.watch(dbProvider))); + +class BackupRepository implements IBackupRepository { + final Isar _db; + + BackupRepository( + this._db, + ); + + @override + Future> getIdsBySelection(BackupSelection backup) => + _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart new file mode 100644 index 0000000000..cd87eb17ec --- /dev/null +++ b/mobile/lib/repositories/user.repository.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final userRepositoryProvider = + Provider((ref) => UserRepository(ref.watch(dbProvider))); + +class UserRepository implements IUserRepository { + final Isar _db; + + UserRepository( + this._db, + ); + + @override + Future> getByIds(List ids) async => + (await _db.users.getAllById(ids)).cast(); +} diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index ef56f9bf6c..92302a0d88 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,6 +5,10 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -12,11 +16,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -26,7 +32,10 @@ final albumServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), - ref.watch(dbProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(backupRepositoryProvider), ), ); @@ -34,7 +43,10 @@ class AlbumService { final ApiService _apiService; final UserService _userService; final SyncService _syncService; - final Isar _db; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + final IBackupRepository _backupAlbumRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -43,16 +55,12 @@ class AlbumService { this._apiService, this._userService, this._syncService, - this._db, + this._albumRepository, + this._assetRepository, + this._userRepository, + this._backupAlbumRepository, ); - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -65,12 +73,12 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final List excludedIds = - await excludedAlbumsQuery().idProperty().findAll(); - final List selectedIds = - await selectedAlbumsQuery().idProperty().findAll(); + final List excludedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.exclude); + final List selectedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { - final numLocal = await _db.albums.where().localIdIsNotNull().count(); + final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } @@ -194,8 +202,8 @@ class AlbumService { ), ); if (remote != null) { - Album album = await Album.remote(remote); - await _db.writeTxn(() => _db.albums.store(album)); + final Album album = await Album.remote(remote); + await _albumRepository.create(album); return album; } } catch (e) { @@ -212,8 +220,7 @@ class AlbumService { for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (null == - await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } @@ -268,20 +275,15 @@ class AlbumService { Future _updateAssets( int albumId, { - Iterable add = const [], - Iterable remove = const [], - }) { - return _db.writeTxn(() async { - final album = await _db.albums.get(albumId); - if (album == null) return; - await album.assets.update(link: add, unlink: remove); - album.startDate = - await album.assets.filter().fileCreatedAtProperty().min(); - album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); - album.lastModifiedAssetTimestamp = - await album.assets.filter().updatedAtProperty().max(); - await _db.albums.put(album); - }); + List add = const [], + List remove = const [], + }) async { + final album = await _albumRepository.getById(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); } Future addAdditionalUserToAlbum( @@ -298,13 +300,9 @@ class AlbumService { AddUsersDto(albumUsers: albumUsers), ); if (result != null) { - album.sharedUsers - .addAll((await _db.users.getAllById(sharedUserIds)).cast()); + album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds)); album.shared = result.shared; - await _db.writeTxn(() async { - await _db.albums.put(album); - await album.sharedUsers.save(); - }); + await _albumRepository.update(album); return true; } } catch (e) { @@ -321,7 +319,7 @@ class AlbumService { ); if (result != null) { album.activityEnabled = enabled; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } } catch (e) { @@ -332,29 +330,29 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final userId = Store.get(StoreKey.currentUser).isarId; - if (album.owner.value?.isarId == userId) { + final user = Store.get(StoreKey.currentUser); + if (album.owner.value?.isarId == user.isarId) { await _apiService.albumsApi.deleteAlbum(album.remoteId!); } if (album.shared) { final foreignAssets = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); - await _db.writeTxn(() => _db.albums.delete(album.id)); - final List albums = - await _db.albums.filter().sharedEqualTo(true).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _albumRepository.delete(album.id); + + final List albums = await _albumRepository.getAll(shared: true); final List existing = []; - for (Album a in albums) { + for (Album album in albums) { existing.addAll( - await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + await _assetRepository.getByAlbum(album, notOwnedBy: user), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); + await _assetRepository.deleteById(idsToRemove); } } else { - await _db.writeTxn(() => _db.albums.delete(album.id)); + await _albumRepository.delete(album.id); } return true; } catch (e) { @@ -390,7 +388,7 @@ class AlbumService { : response .where((e) => e.success) .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove); + await _updateAssets(album.id, remove: toRemove.toList()); return true; } } catch (e) { @@ -410,12 +408,10 @@ class AlbumService { ); album.sharedUsers.remove(user); - await _db.writeTxn(() async { - await album.sharedUsers.update(unlink: [user]); - final a = await _db.albums.get(album.id); - // trigger watcher - await _db.albums.put(a!); - }); + await _albumRepository.removeUsers(album, [user]); + final a = await _albumRepository.getById(album.id); + // trigger watcher + await _albumRepository.update(a!); return true; } catch (e) { @@ -436,7 +432,7 @@ class AlbumService { ), ); album.name = newAlbumTitle; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } catch (e) { @@ -445,14 +441,8 @@ class AlbumService { } } - Future getAlbumByName(String name, bool remoteOnly) async { - return _db.albums - .filter() - .optional(remoteOnly, (q) => q.localIdIsNull()) - .nameEqualTo(name) - .sharedEqualTo(false) - .findFirst(); - } + Future getAlbumByName(String name, bool remoteOnly) => + _albumRepository.getByName(name, remote: remoteOnly ? true : null); /// /// Add the uploaded asset to the selected albums diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index fc3feb174d..0d4d547434 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -12,6 +12,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -355,12 +359,23 @@ class BackgroundService { AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); PartnerService partnerService = PartnerService(apiService, db); + AlbumRepository albumRepository = AlbumRepository(db); + AssetRepository assetRepository = AssetRepository(db); + UserRepository userRepository = UserRepository(db); + BackupRepository backupAlbumRepository = BackupRepository(db); HashService hashService = HashService(db, this); SyncService syncSerive = SyncService(db, hashService); UserService userService = UserService(apiService, db, syncSerive, partnerService); - AlbumService albumService = - AlbumService(apiService, userService, syncSerive, db); + AlbumService albumService = AlbumService( + apiService, + userService, + syncSerive, + albumRepository, + assetRepository, + userRepository, + backupAlbumRepository, + ); BackupService backupService = BackupService(apiService, db, settingService, albumService); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart new file mode 100644 index 0000000000..e54d82739e --- /dev/null +++ b/mobile/test/repository.mocks.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAlbumRepository extends Mock implements IAlbumRepository {} + +class MockAssetRepository extends Mock implements IAssetRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockBackupRepository extends Mock implements IBackupRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart new file mode 100644 index 0000000000..ba4c129e5c --- /dev/null +++ b/mobile/test/service.mocks.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSyncService extends Mock implements SyncService {} diff --git a/mobile/test/services/album.service.test.dart b/mobile/test/services/album.service.test.dart new file mode 100644 index 0000000000..790a0eba35 --- /dev/null +++ b/mobile/test/services/album.service.test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +void main() { + late AlbumService sut; + late MockApiService apiService; + late MockUserService userService; + late MockSyncService syncService; + late MockAlbumRepository albumRepository; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + late MockBackupRepository backupRepository; + + setUp(() { + apiService = MockApiService(); + userService = MockUserService(); + syncService = MockSyncService(); + albumRepository = MockAlbumRepository(); + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + backupRepository = MockBackupRepository(); + + sut = AlbumService( + apiService, + userService, + syncService, + albumRepository, + assetRepository, + userRepository, + backupRepository, + ); + }); + + group('refreshDeviceAlbums', () { + test('empty selection with one album in db', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => []); + when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); + when(() => syncService.removeAllLocalAlbumsAndAssets()) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, false); + verify(() => syncService.removeAllLocalAlbumsAndAssets()); + }); + }); +} From b74b20824a1c0aa238a08e59a327307526016ad3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 16 Sep 2024 16:49:12 -0400 Subject: [PATCH 004/599] feat: tag cleanup job (#12654) --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/jobs_api.dart | 39 ++++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/job_create_dto.dart | 98 +++++++++++++++++++ mobile/openapi/lib/model/manual_job_name.dart | 88 +++++++++++++++++ open-api/immich-openapi-specs.json | 52 ++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 17 ++++ server/src/controllers/job.controller.ts | 10 +- server/src/dtos/job.dto.ts | 7 ++ server/src/enum.ts | 6 ++ server/src/interfaces/job.interface.ts | 6 ++ server/src/interfaces/tag.interface.ts | 1 + server/src/repositories/job.repository.ts | 3 + server/src/repositories/tag.repository.ts | 39 +++++++- server/src/services/job.service.ts | 28 +++++- server/src/services/microservices.service.ts | 3 + server/src/services/tag.service.ts | 6 ++ .../test/repositories/tag.repository.mock.ts | 1 + .../shared-components/combobox.svelte | 2 +- web/src/lib/i18n/en.json | 6 ++ web/src/routes/admin/jobs-status/+page.svelte | 62 +++++++++++- 23 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/lib/model/job_create_dto.dart create mode 100644 mobile/openapi/lib/model/manual_job_name.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 36b2c7bbf4..16f293f81a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -341,6 +343,7 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145..915c70f08e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -144,6 +144,7 @@ part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -155,6 +156,7 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126..78afc15c93 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc8..6a40de730c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -343,6 +343,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -365,6 +367,8 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f5..0f3cc41097 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); + } if (value is MapTheme) { return MapThemeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart new file mode 100644 index 0000000000..a4734791bb --- /dev/null +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ + required this.name, + }); + + ManualJobName name; + + @override + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (name.hashCode); + + @override + String toString() => 'JobCreateDto[name=$name]'; + + Map toJson() { + final json = {}; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [JobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static JobCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = JobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = JobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of JobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000..7e8d9d51b2 --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b4ec4505b9..af79815563 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2561,6 +2561,39 @@ "tags": [ "Jobs" ] + }, + "post": { + "operationId": "createJob", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Jobs" + ] } }, "/jobs/{id}": { @@ -9269,6 +9302,17 @@ ], "type": "object" }, + "JobCreateDto": { + "properties": { + "name": { + "$ref": "#/components/schemas/ManualJobName" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "JobName": { "enum": [ "thumbnailGeneration", @@ -9511,6 +9555,14 @@ ], "type": "object" }, + "ManualJobName": { + "enum": [ + "person-cleanup", + "tag-cleanup", + "user-cleanup" + ], + "type": "string" + }, "MapMarkerResponseDto": { "properties": { "city": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9350bd5604..da57313692 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -548,6 +548,9 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; force: boolean; @@ -1941,6 +1944,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -3364,6 +3376,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab..7da19e207f 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf..895f710b7a 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ManualJobName } from 'src/enum'; import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean } from 'src/validation'; @@ -20,6 +21,12 @@ export class JobCommandDto { force!: boolean; } +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; +} + export class JobCountsDto { @ApiProperty({ type: 'integer' }) active!: number; diff --git a/server/src/enum.ts b/server/src/enum.ts index 32254854e4..d76d97371c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -186,3 +186,9 @@ export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', } + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index a0533fa63f..d0a15bfa5d 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -60,6 +60,9 @@ export enum JobName { STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + // tags + TAG_CLEANUP = 'tag-cleanup', + // migration QUEUE_MIGRATION = 'queue-migration', MIGRATE_ASSET = 'migrate-asset', @@ -262,6 +265,9 @@ export type JobItem = | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index aca9c223d5..16a34d6ac4 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset { upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; upsertAssetIds(items: AssetTagItem[]): Promise; + deleteEmptyTags(): Promise; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5..2981fa4bdd 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record = { [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9389aeb13b..1a5415b8db 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; @Instrumentation() @Injectable() @@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise { return this.repository.findOne({ where: { id } }); @@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository { }); } + async deleteEmptyTags() { + await this.dataSource.transaction(async (manager) => { + const ids = new Set(); + const tags = await manager.find(TagEntity); + for (const tag of tags) { + const count = await manager + .createQueryBuilder('assets', 'asset') + .innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: tag.id }, + ) + .getCount(); + + if (count === 0) { + this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); + ids.add(tag.id); + } + } + + if (ids.size > 0) { + await manager.delete(TagEntity, { id: In([...ids]) }); + this.logger.log(`Deleted ${ids.size} empty tags`); + } + }); + } + private async save(partial: Partial): Promise { const { id } = await this.repository.save(partial); return this.repository.findOneOrFail({ where: { id } }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa61ccf3cb..03a6edf126 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/enum'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { @@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; + @Injectable() export class JobService { private configCore: SystemConfigCore; @@ -39,6 +59,10 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + async create(dto: JobCreateDto): Promise { + await this.jobRepository.queue(asJobItem(dto)); + } + async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 025400cc9b..df4b072d56 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -15,6 +15,7 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @@ -34,6 +35,7 @@ export class MicroservicesService { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, @@ -93,6 +95,7 @@ export class MicroservicesService { [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); } diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 97b0ef1be6..cc6d64f749 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -138,6 +139,11 @@ export class TagService { return results; } + async handleTagCleanup() { + await this.repository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + private async findOrFail(id: string) { const tag = await this.repository.get(id); if (!tag) { diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a3fc0e77e0..acc2b59f6d 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked => { addAssetIds: vitest.fn(), removeAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(), + deleteEmptyTags: vitest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index d3e022a759..7c71fe8aea 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -220,7 +220,7 @@ role="listbox" id={listboxId} transition:fly={{ duration: 250 }} - class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10" + class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]" class:border={isOpen} tabindex="-1" > diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f880dab347..a788666050 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -41,6 +41,7 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "create_job": "Create job", "disable_login": "Disable login", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Thumbnail resolution", "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", "job_concurrency": "{job} concurrency", + "job_created": "Job created", "job_not_concurrency_safe": "This job is not concurrency-safe.", "job_settings": "Job Settings", "job_settings_description": "Manage job concurrency", @@ -196,6 +198,7 @@ "password_settings": "Password Login", "password_settings_description": "Manage password login settings", "paths_validated_successfully": "All paths validated successfully", + "person_cleanup_job": "Person cleanup", "quota_size_gib": "Quota Size (GiB)", "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "scanning_library_for_changed_files": "Scanning library for changed files", "scanning_library_for_new_files": "Scanning library for new files", + "search_jobs": "Search jobs...", "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_user_label": "{label} is the user's Storage Label", "system_settings": "System Settings", + "tag_cleanup_job": "Tag cleanup", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -309,6 +314,7 @@ "trash_settings_description": "Manage trash settings", "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", + "user_cleanup_job": "User cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index dcd6630a01..16c2541e61 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -3,10 +3,17 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import { asyncTimeout } from '$lib/utils'; - import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; - import { mdiCog } from '@mdi/js'; + import { handleError } from '$lib/utils/handle-error'; + import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk'; + import { mdiCog, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -16,6 +23,8 @@ let jobs: AllJobStatusResponseDto; let running = true; + let isOpen = false; + let selectedJob: ComboBoxOption | undefined = undefined; onMount(async () => { while (running) { @@ -27,10 +36,38 @@ onDestroy(() => { running = false; }); + + const options = [ + { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, + { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, + { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, + ].map(({ value, title }) => ({ id: value, label: title, value })); + + const handleCancel = () => (isOpen = false); + + const handleCreate = async () => { + if (!selectedJob) { + return; + } + + try { + await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } }); + notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info }); + handleCancel(); + } catch (error) { + handleError(error, $t('errors.unable_to_submit_job')); + } + };
+ (isOpen = true)}> +
+ + {$t('admin.create_job')} +
+
@@ -46,3 +83,24 @@ + +{#if isOpen} + +
+
+ +
+
+
+{/if} From 186b4e133336300a1ead4876c9838e0a23b310c9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Sep 2024 15:51:03 -0500 Subject: [PATCH 005/599] feat(web): improve UI/UX for settings pages (#12626) * fix(web): local date time for buckets * feat(web): improve UI/UX for setting pages * search admin settings and icon * clean up * fix translation file * Update web/src/routes/admin/system-settings/+page.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * Update web/src/lib/components/shared-components/settings/setting-accordion.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * better search bar on smaller screen * lint * template syntax --------- Co-authored-by: Jason Rasmussen Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> --- .../settings/auth/auth-settings.svelte | 2 +- .../settings/setting-accordion.svelte | 22 +++++-- .../feature-settings.svelte | 2 +- .../user-settings-list.svelte | 57 ++++++++++++++--- web/src/lib/i18n/en.json | 1 + .../routes/admin/system-settings/+page.svelte | 62 +++++++++++++++++-- 6 files changed, 126 insertions(+), 20 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 37f875c604..9b0e4b3270 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -71,7 +71,7 @@
-
+
-
+
{/each} diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index d933b27ab5..24b539f0a1 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -43,11 +43,5 @@
- onToggle(detail)} - ariaDescribedBy={subtitleId} - /> +
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index ebc0dd688c..2bd1b8976b 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -1,9 +1,8 @@ - dispatch('close')}> +
{#if shortcuts.general.length > 0}
diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 676e984364..d43977ea08 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -15,14 +15,10 @@ mdiUbuntu, } from '@mdi/js'; import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; export let device: SessionResponseDto; - - const dispatcher = createEventDispatcher<{ - delete: void; - }>(); + export let onDelete: (() => void) | undefined = undefined; const options: ToRelativeCalendarOptions = { unit: 'days', @@ -68,14 +64,14 @@
- {#if !device.current} + {#if !device.current && onDelete}
dispatcher('delete')} + on:click={onDelete} />
{/if} diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 57299bb46f..26e03c35d8 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -68,7 +68,7 @@ {$t('other_devices').toUpperCase()} {#each otherDevices as device, index} - handleDelete(device)} /> + handleDelete(device)} /> {#if index !== otherDevices.length - 1}
{/if} diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte index 3cff1cd1de..8ab747aa27 100644 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte @@ -1,19 +1,18 @@ - dispatch('close')}> +

{$t('settings').toUpperCase()}

@@ -68,14 +64,14 @@ title={$t('comments_and_likes')} subtitle={$t('let_others_respond')} checked={album.isActivityEnabled} - on:toggle={() => dispatch('toggleEnableActivity')} + onToggle={onToggleEnabledActivity} />
{$t('people').toUpperCase()}
-
{/key} @@ -152,10 +151,8 @@ rounded="full" disabled={Object.keys(selectedUsers).length === 0} on:click={() => - dispatch( - 'select', - Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), - )}>{$t('add')} ({ userId: user.id, ...rest })))} + >{$t('add')}
{/if} @@ -166,7 +163,7 @@ -
- onSelect(detail)} /> + diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index d781e1cc56..f869790eba 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -4,7 +4,6 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { type PersonResponseDto } from '@immich/sdk'; import { mdiArrowLeft, mdiMerge } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import Button from '../elements/buttons/button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -13,25 +12,22 @@ export let personMerge1: PersonResponseDto; export let personMerge2: PersonResponseDto; export let potentialMergePeople: PersonResponseDto[]; + export let onReject: () => void; + export let onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void; + export let onClose: () => void; let choosePersonToMerge = false; const title = personMerge2.name; - const dispatch = createEventDispatcher<{ - reject: void; - confirm: [PersonResponseDto, PersonResponseDto]; - close: void; - }>(); - - const changePersonToMerge = (newperson: PersonResponseDto) => { - const index = potentialMergePeople.indexOf(newperson); + const changePersonToMerge = (newPerson: PersonResponseDto) => { + const index = potentialMergePeople.indexOf(newPerson); [potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]]; choosePersonToMerge = false; }; - dispatch('close')}> +
{#if !choosePersonToMerge}
@@ -105,7 +101,7 @@

{$t('they_will_be_merged_together')}

- - + + diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 21f48e42eb..6791a26232 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -9,7 +9,6 @@ mdiDotsVertical, mdiEyeOffOutline, } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import { t } from 'svelte-i18n'; @@ -18,19 +17,12 @@ export let person: PersonResponseDto; export let preload = false; - - type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person'; - let dispatch = createEventDispatcher<{ - 'change-name': void; - 'set-birth-date': void; - 'merge-people': void; - 'hide-person': void; - }>(); + export let onChangeName: () => void; + export let onSetBirthDate: () => void; + export let onMergePeople: () => void; + export let onHidePerson: () => void; let showVerticalDots = false; - const onMenuClick = (event: MenuItemEvent) => { - dispatch(event); - };
- onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} /> - onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} /> - onMenuClick('set-birth-date')} - icon={mdiCalendarEditOutline} - text={$t('set_date_of_birth')} - /> - onMenuClick('merge-people')} - icon={mdiAccountMultipleCheckOutline} - text={$t('merge_people')} - /> + + + +
{/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 5130baf30b..230c8750ae 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -1,6 +1,5 @@ - +

{$t('birthdate_set_description')}

- handleSubmit()} autocomplete="off" id="set-birth-date-form"> + onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form">
- + diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index c89c8338d3..753e46c219 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -10,7 +10,7 @@ type PersonResponseDto, } from '@immich/sdk'; import { mdiMerge, mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -23,6 +23,8 @@ export let assetIds: string[]; export let personAssets: PersonResponseDto; + export let onConfirm: () => void; + export let onClose: () => void; let people: PersonResponseDto[] = []; let selectedPerson: PersonResponseDto | null = null; @@ -34,11 +36,6 @@ $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets]; - let dispatch = createEventDispatcher<{ - confirm: void; - close: void; - }>(); - const selectedPeople: AssetFaceUpdateItem[] = []; for (const assetId of assetIds) { @@ -50,10 +47,6 @@ people = data.people; }); - const onClose = () => { - dispatch('close'); - }; - const handleSelectedPerson = (person: PersonResponseDto) => { if (selectedPerson && selectedPerson.id === person.id) { handleRemoveSelectedPerson(); @@ -87,7 +80,7 @@ } showLoadingSpinnerCreate = false; - dispatch('confirm'); + onConfirm(); }; const handleReassign = async () => { @@ -113,7 +106,7 @@ } showLoadingSpinnerReassign = false; - dispatch('confirm'); + onConfirm(); }; @@ -123,7 +116,7 @@ transition:fly={{ y: 500, duration: 100, easing: quintOut }} class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" > - +
@@ -180,7 +173,7 @@
{/if} - handleSelectedPerson(detail)} /> + diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index b7bf8e1836..f43e1da38e 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -1,20 +1,15 @@ - handleDone()}> +

{$t('api_key_description')} @@ -28,6 +23,6 @@ - + diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte index 799dde7ef3..cbf2ff07f0 100644 --- a/web/src/lib/components/forms/change-password-form.svelte +++ b/web/src/lib/components/forms/change-password-form.svelte @@ -1,10 +1,11 @@ diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 8f049685a4..9c4b83002b 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -5,13 +5,14 @@ import { ByteUnit, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { createUserAdmin } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import Button from '../elements/buttons/button.svelte'; import Slider from '../elements/slider.svelte'; import PasswordField from '../shared-components/password-field.svelte'; export let onClose: () => void; + export let onSubmit: () => void; + export let onCancel: () => void; let error: string; let success: string; @@ -39,10 +40,6 @@ canCreateUser = true; } } - const dispatch = createEventDispatcher<{ - submit: void; - cancel: void; - }>(); async function registerUser() { if (canCreateUser && !isCreatingUser) { @@ -63,7 +60,7 @@ success = $t('new_user_created'); - dispatch('submit'); + onSubmit(); return; } catch (error) { @@ -132,7 +129,7 @@ {/if} - + diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index b326565122..0079a695bc 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -5,7 +5,6 @@ import { handleError } from '$lib/utils/handle-error'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { mdiAccountEditOutline } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import Button from '../elements/buttons/button.svelte'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; @@ -15,6 +14,8 @@ export let canResetPassword = true; export let newPassword: string; export let onClose: () => void; + export let onResetPasswordSuccess: () => void; + export let onEditSuccess: () => void; let error: string; let success: string; @@ -27,12 +28,6 @@ !!quotaSize && convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw; - const dispatch = createEventDispatcher<{ - close: void; - resetPasswordSuccess: void; - editSuccess: void; - }>(); - const editUser = async () => { try { const { id, email, name, storageLabel } = user; @@ -46,7 +41,7 @@ }, }); - dispatch('editSuccess'); + onEditSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_update_user')); } @@ -72,7 +67,7 @@ }, }); - dispatch('resetPasswordSuccess'); + onResetPasswordSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_reset_password')); } diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte index c09f1fbaf6..05d47c0a0f 100644 --- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte +++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte @@ -1,5 +1,4 @@ - -

handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form"> + + onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form">

{$t('admin.exclusion_pattern_description')}

@@ -53,9 +47,9 @@

- + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte index f82d573386..8bfca80aec 100644 --- a/web/src/lib/components/forms/library-import-path-form.svelte +++ b/web/src/lib/components/forms/library-import-path-form.svelte @@ -1,5 +1,4 @@ - -
handleSubmit()} autocomplete="off" id="library-import-path-form"> + + onSubmit(importPath)} autocomplete="off" id="library-import-path-form">

{$t('admin.library_import_path_description')}

@@ -47,9 +41,9 @@
- + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index a2bb3a9686..9e7ae11a63 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -1,5 +1,5 @@ -
handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2"> + onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-2">
- +
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte index 5e025a406a..a9a42c31f7 100644 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ b/web/src/lib/components/forms/library-scan-settings-form.svelte @@ -1,7 +1,7 @@ - -
handleSubmit()} autocomplete="off" id="select-library-owner-form"> + + onSubmit(ownerId)} autocomplete="off" id="select-library-owner-form">

{$t('admin.note_cannot_be_changed_later')}

- +
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 5bca13b060..ed232b80cd 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -21,7 +21,7 @@
{#if !hideNavbar} - openFileUploadDialog()} /> + openFileUploadDialog()} /> {/if} diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index b442396c84..35df9f2285 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -4,7 +4,6 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import type { MapSettings } from '$lib/stores/preferences.store'; import { Duration } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -12,19 +11,15 @@ import DateInput from '../elements/date-input.svelte'; export let settings: MapSettings; + export let onClose: () => void; + export let onSave: (settings: MapSettings) => void; + let customDateRange = !!settings.dateAfter || !!settings.dateBefore; - - const dispatch = createEventDispatcher<{ - close: void; - save: MapSettings; - }>(); - - const handleClose = () => dispatch('close'); - +
dispatch('save', settings)} + on:submit|preventDefault={() => onSave(settings)} class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" id="map-settings-form" > @@ -108,7 +103,7 @@ {/if}
- +
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index ae6416873e..919433f79b 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -250,7 +250,7 @@
{#if current && current.memory.assets.length > 0} - goto(AppRoute.PHOTOS)} forceDark> + goto(AppRoute.PHOTOS)} forceDark>

{$memoryLaneTitle(current.memory.yearsAgo)} diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 976f4bd9cf..d3998510cd 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -40,8 +40,8 @@ {#if showAlbumPicker} handleAddToNewAlbum(detail)} - on:album={({ detail }) => handleAddToAlbum(detail)} + onNewAlbum={handleAddToNewAlbum} + onAlbumClick={handleAddToAlbum} onClose={handleHideAlbumPicker} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index 6ee775fa69..114315348d 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -31,9 +31,5 @@ (isShowChangeDate = true)} /> {/if} {#if isShowChangeDate} - handleConfirm(date)} - on:cancel={() => (isShowChangeDate = false)} - /> + (isShowChangeDate = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte index 0e19696a42..3fe1db4327 100644 --- a/web/src/lib/components/photos-page/actions/change-location-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte @@ -35,8 +35,5 @@ /> {/if} {#if isShowChangeLocation} - handleConfirm(point)} - on:cancel={() => (isShowChangeLocation = false)} - /> + (isShowChangeLocation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 5c79e7b221..6d3275c74d 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -49,7 +49,7 @@ {#if isShowConfirmation} (isShowConfirmation = false)} + onConfirm={handleDelete} + onCancel={() => (isShowConfirmation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 240b6c2ba2..b2780cc1a0 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -8,7 +8,7 @@ import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { TUNABLES } from '$lib/utils/tunables'; @@ -29,6 +29,9 @@ export let onScrollTarget: ScrollTargetListener | undefined = undefined; export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; + export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; + export let onSelectAssets: (asset: AssetResponseDto) => void; + export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; const componentId = generateId(); $: bucketDate = bucket.bucketDate; @@ -41,11 +44,6 @@ const TITLE_HEIGHT = 51; const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; - const dispatch = createEventDispatcher<{ - select: { title: string; assets: AssetResponseDto[] }; - selectAssets: AssetResponseDto; - selectAssetCandidates: AssetResponseDto | null; - }>(); let isMouseOverGroup = false; let hoveredDateGroup = ''; @@ -65,10 +63,10 @@ } }; - const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets }); + const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { - dispatch('selectAssets', asset); + onSelectAssets(asset); // Check if all assets are selected in a group to toggle the group selection's icon let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; @@ -86,7 +84,7 @@ hoveredDateGroup = groupTitle; if ($isMultiSelectState) { - dispatch('selectAssetCandidates', asset); + onSelectAssetCandidates(asset); } }; diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3bf0c65bc9..6de36c803e 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -28,7 +28,7 @@ import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import { throttle } from 'lodash-es'; - import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; @@ -64,6 +64,8 @@ export let isShared = false; export let album: AlbumResponseDto | null = null; export let isShowDeleteConfirmation = false; + export let onSelect: (asset: AssetResponseDto) => void = () => {}; + export let onEscape: () => void = () => {}; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = @@ -127,8 +129,6 @@ }, } = TUNABLES; - const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>(); - const isViewportOrigin = () => { return viewport.height === 0 && viewport.width === 0; }; @@ -447,7 +447,7 @@ const ids = await stackAssets(Array.from($selectedAssets)); if (ids) { $assetStore.removeAssets(ids); - dispatch('escape'); + onEscape(); } }; @@ -471,7 +471,7 @@ } const shortcuts: ShortcutOptions[] = [ - { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') }, + { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, @@ -539,7 +539,7 @@ return !!nextAsset; }; - const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => { + const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { assetViewingStore.showAssetViewer(false); showSkeleton = true; $gridScrollTarget = { at: asset.id }; @@ -554,7 +554,7 @@ case AssetAction.DELETE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } })); + (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); // delete after find the next one assetStore.removeAssets([action.asset.id]); @@ -649,7 +649,7 @@ return; } - dispatch('select', asset); + onSelect(asset); if (singleSelect) { element.scrollTop = 0; @@ -754,8 +754,8 @@ {#if isShowDeleteConfirmation} (isShowDeleteConfirmation = false)} - on:confirm={() => handlePromiseError(trashOrDelete(true))} + onCancel={() => (isShowDeleteConfirmation = false)} + onConfirm={() => handlePromiseError(trashOrDelete(true))} /> {/if} @@ -847,9 +847,9 @@ {onAssetInGrid} {bucket} viewport={safeViewport} - on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + onSelect={({ title, assets }) => handleGroupSelect(title, assets)} + onSelectAssetCandidates={handleSelectAssetCandidates} + onSelectAssets={handleSelectAssets} /> {/if}

@@ -869,9 +869,9 @@ {isShared} {album} onAction={handleAction} - on:previous={handlePrevious} - on:next={handleNext} - on:close={handleClose} + onPrevious={handlePrevious} + onNext={handleNext} + onClose={handleClose} /> {/await} {/if} diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index c802c53454..79a0ea75e6 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -30,7 +30,7 @@ }); - +

{assets.size}

diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 84782b2d7f..3eff428a7b 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,5 +1,4 @@ @@ -27,7 +23,7 @@ title={$t('permanently_delete_assets_count', { values: { count: size } })} confirmText={$t('delete')} onConfirm={handleConfirm} - onCancel={() => dispatch('cancel')} + {onCancel} >

diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte index 0690374c01..6d28bd12c0 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -2,7 +2,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; import { mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import AlbumListItem from '../asset-viewer/album-list-item.svelte'; import { normalizeSearchString } from '$lib/utils/string-utils'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; @@ -11,17 +11,15 @@ import { sortAlbums } from '$lib/utils/album-utils'; import { albumViewSettings } from '$lib/stores/preferences.store'; + export let onNewAlbum: (search: string) => void; + export let onAlbumClick: (album: AlbumResponseDto) => void; + let albums: AlbumResponseDto[] = []; let recentAlbums: AlbumResponseDto[] = []; let filteredAlbums: AlbumResponseDto[] = []; let loading = true; let search = ''; - const dispatch = createEventDispatcher<{ - newAlbum: string; - album: AlbumResponseDto; - }>(); - export let shared: boolean; export let onClose: () => void; @@ -40,14 +38,6 @@ { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, ); - const handleSelect = (album: AlbumResponseDto) => { - dispatch('album', album); - }; - - const handleNew = () => { - dispatch('newAlbum', search.length > 0 ? search : ''); - }; - const getTitle = () => { if (shared) { return $t('add_to_shared_album'); @@ -81,7 +71,7 @@

@@ -180,7 +162,7 @@ center={lat && lng ? { lat, lng } : undefined} simplified={true} clickable={true} - on:clickedPoint={({ detail: point }) => handleSelect(point)} + onClickPoint={(selected) => (point = selected)} /> {/await}
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7c71fe8aea..241f937be0 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -21,7 +21,7 @@ import { fly } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; - import { createEventDispatcher, tick } from 'svelte'; + import { tick } from 'svelte'; import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/actions/shortcut'; import { focusOutside } from '$lib/actions/focus-outside'; @@ -35,6 +35,7 @@ export let options: ComboBoxOption[] = []; export let selectedOption: ComboBoxOption | undefined = undefined; export let placeholder = ''; + export let onSelect: (option: ComboBoxOption | undefined) => void = () => {}; /** * Unique identifier for the combobox. @@ -61,10 +62,6 @@ searchQuery = selectedOption ? selectedOption.label : ''; } - const dispatch = createEventDispatcher<{ - select: ComboBoxOption | undefined; - }>(); - const activate = () => { isActive = true; searchQuery = ''; @@ -105,10 +102,10 @@ optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; - let onSelect = (option: ComboBoxOption) => { + let handleSelect = (option: ComboBoxOption) => { selectedOption = option; searchQuery = option.label; - dispatch('select', option); + onSelect(option); closeDropdown(); }; @@ -117,7 +114,7 @@ selectedIndex = undefined; selectedOption = undefined; searchQuery = ''; - dispatch('select', selectedOption); + onSelect(selectedOption); }; @@ -188,7 +185,7 @@ shortcut: { key: 'Enter' }, onShortcut: () => { if (selectedIndex !== undefined && filteredOptions.length > 0) { - onSelect(filteredOptions[selectedIndex]); + handleSelect(filteredOptions[selectedIndex]); } closeDropdown(); }, @@ -245,7 +242,7 @@ bind:this={optionRefs[index]} class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" id={`${listboxId}-${index}`} - on:click={() => onSelect(option)} + on:click={() => handleSelect(option)} role="option" > {option.label} diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index cf128104d1..228cd88a86 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index 24b539f0a1..11716526f8 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -1,7 +1,6 @@
diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 13ec440082..a63bdb3ca9 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -102,7 +102,7 @@ {/if} {#if secret} - (secret = '')} /> + (secret = '')} /> {/if} {#if editKey} diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2f1efc487c..fd5b68d8c3 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -151,15 +151,15 @@ 1} - on:next={() => { + onNext={() => { const index = getAssetIndex($viewingAsset.id) + 1; setAsset(assets[index % assets.length]); }} - on:previous={() => { + onPrevious={() => { const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => { + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e75273f3b..57d09ed53a 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -674,8 +674,8 @@ disabled={!album.isActivityEnabled} {isLiked} numberOfComments={$numberOfComments} - on:favorite={handleFavorite} - on:openActivityTab={handleOpenAndCloseActivityTab} + onFavorite={handleFavorite} + onOpenActivityTab={handleOpenAndCloseActivityTab} />
{/if} @@ -697,10 +697,10 @@ albumId={album.id} {isLiked} bind:reactions - on:addComment={() => updateNumberOfComments(1)} - on:deleteComment={() => updateNumberOfComments(-1)} - on:deleteLike={() => (isLiked = null)} - on:close={handleOpenAndCloseActivityTab} + onAddComment={() => updateNumberOfComments(1)} + onDeleteComment={() => updateNumberOfComments(-1)} + onDeleteLike={() => (isLiked = null)} + onClose={handleOpenAndCloseActivityTab} />
@@ -709,8 +709,8 @@ {#if viewMode === ViewMode.SELECT_USERS} handleAddUsers(users)} - on:share={() => (viewMode = ViewMode.LINK_SHARING)} + onSelect={handleAddUsers} + onShare={() => (viewMode = ViewMode.LINK_SHARING)} onClose={() => (viewMode = ViewMode.VIEW)} /> {/if} @@ -723,8 +723,8 @@ (viewMode = ViewMode.VIEW)} {album} - on:remove={({ detail: userId }) => handleRemoveUser(userId)} - on:refreshAlbum={refreshAlbum} + onRemove={handleRemoveUser} + onRefreshAlbum={refreshAlbum} /> {/if} @@ -737,9 +737,9 @@ albumOrder = order; await setModeToView(); }} - on:close={() => (viewMode = ViewMode.VIEW)} - on:toggleEnableActivity={handleToggleEnableActivity} - on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} + onClose={() => (viewMode = ViewMode.VIEW)} + onToggleEnabledActivity={handleToggleEnableActivity} + onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} /> {/if} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0ea0ed18bb..2e109823ed 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -122,9 +122,9 @@ 1} - on:next={navigateNext} - on:previous={navigatePrevious} - on:close={() => { + onNext={navigateNext} + onPrevious={navigatePrevious} + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} @@ -137,11 +137,11 @@ {#if showSettingsModal} (showSettingsModal = false)} - on:save={async ({ detail }) => { - const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); + onClose={() => (showSettingsModal = false)} + onSave={async (settings) => { + const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); showSettingsModal = false; - $mapSettings = detail; + $mapSettings = settings; if (shouldUpdate) { mapMarkers = await loadMapMarkers(); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f1a2674e24..b6d25c48bf 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -302,9 +302,9 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (showMergeModal = false)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (showMergeModal = false)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} @@ -349,10 +349,10 @@ handleChangeName(person)} - on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-people={() => handleMergePeople(person)} - on:hide-person={() => handleHidePerson(person)} + onChangeName={() => handleChangeName(person)} + onSetBirthDate={() => handleSetBirthDate(person)} + onMergePeople={() => handleMergePeople(person)} + onHidePerson={() => handleHidePerson(person)} /> {:else} @@ -397,8 +397,8 @@ {#if showSetBirthDateModal} (showSetBirthDateModal = false)} - on:updated={(event) => submitBirthDateChange(event.detail)} + onClose={() => (showSetBirthDateModal = false)} + onUpdate={submitBirthDateChange} /> {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index daa5821e85..bb648228b9 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -347,8 +347,8 @@ a.id)} personAssets={person} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:confirm={handleUnmerge} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} /> {/if} @@ -357,22 +357,22 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} {#if viewMode === ViewMode.BIRTH_DATE} (viewMode = ViewMode.VIEW_ASSETS)} - on:updated={(event) => handleSetBirthDate(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onUpdate={handleSetBirthDate} /> {/if} {#if viewMode === ViewMode.MERGE_PEOPLE} - handleMerge(detail)} /> + {/if}
@@ -464,7 +464,7 @@ bind:suggestedPeople name={person.name} bind:isSearchingPeople - on:change={(event) => handleNameChange(event.detail)} + onChange={handleNameChange} {thumbnailData} /> {:else} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 74db5628ba..5ce3296a03 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -267,10 +267,7 @@ {#if toCreateLibrary} - handleCreate(detail.ownerId)} - on:cancel={() => (toCreateLibrary = false)} - /> + (toCreateLibrary = false)} /> {/if} @@ -385,28 +382,20 @@ {#if renameLibrary === index}
- handleUpdate(detail)} - on:cancel={() => (renameLibrary = null)} - /> + (renameLibrary = null)} />
{/if} {#if editImportPaths === index}
- handleUpdate(detail)} - on:cancel={() => (editImportPaths = null)} - /> + (editImportPaths = null)} />
{/if} {#if editScanSettings === index}
handleUpdate(library)} - on:cancel={() => (editScanSettings = null)} + onSubmit={handleUpdate} + onCancel={() => (editScanSettings = null)} />
{/if} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index b040ce293c..2313b17cb1 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -110,8 +110,8 @@
{#if shouldShowCreateUserForm} (shouldShowCreateUserForm = false)} + onSubmit={onUserCreated} + onCancel={() => (shouldShowCreateUserForm = false)} onClose={() => (shouldShowCreateUserForm = false)} /> {/if} @@ -121,8 +121,8 @@ user={selectedUser} bind:newPassword canResetPassword={selectedUser?.id !== $user.id} - on:editSuccess={onEditUserSuccess} - on:resetPasswordSuccess={onEditPasswordSuccess} + onEditSuccess={onEditUserSuccess} + onResetPasswordSuccess={onEditPasswordSuccess} onClose={() => (shouldShowEditUserForm = false)} /> {/if} diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index aa23e4e7d2..eaf5a88fe2 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -25,5 +25,5 @@ {$t('change_password_description')}

- + From 8cd3f6b8840a8f8f66c42d40dc694aac2307e930 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 21 Sep 2024 00:24:46 +0200 Subject: [PATCH 028/599] fix(web): events as props (#12825) --- .../admin-page/settings/ffmpeg/ffmpeg-settings.svelte | 4 ++-- .../admin-page/settings/image/image-settings.svelte | 4 ++-- .../asset-viewer/video-wrapper-viewer.svelte | 10 +++++++++- web/src/lib/components/faces-page/people-list.svelte | 2 +- web/src/lib/components/faces-page/people-search.svelte | 4 ++-- .../components/faces-page/unmerge-face-selector.svelte | 2 +- web/src/lib/components/forms/tag-asset-form.svelte | 2 +- .../share-page/individual-shared-viewer.svelte | 2 +- .../purchasing/purchase-activation-success.svelte | 2 +- .../search-bar/search-camera-section.svelte | 4 ++-- .../search-bar/search-location-section.svelte | 6 +++--- .../shared-components/settings/setting-combobox.svelte | 9 +-------- .../components/user-settings-page/app-settings.svelte | 10 +++++----- .../user-settings-page/partner-settings.svelte | 2 +- .../user-settings-page/user-purchase-settings.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 +++++----- .../map/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 ++++---- .../routes/(user)/photos/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/sharing/sharedlinks/+page.svelte | 2 +- 22 files changed, 47 insertions(+), 46 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 7ddb71cbde..c048a22207 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -99,7 +99,7 @@ ]} name="vcodec" isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - on:select={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} /> + onSelect={() => config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) ? null : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index a7b47920fd..d6fc814b98 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -96,7 +96,7 @@ title={$t('admin.image_prefer_wide_gamut')} subtitle={$t('admin.image_prefer_wide_gamut_setting_description')} checked={config.image.colorspace === Colorspace.P3} - on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)} + onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> @@ -105,7 +105,7 @@ title={$t('admin.image_prefer_embedded_preview')} subtitle={$t('admin.image_prefer_embedded_preview_setting_description')} checked={config.image.extractEmbedded} - on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} {disabled} /> diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index ae9fda8c69..5f03784c42 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -15,5 +15,13 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 230c8750ae..10626a6a93 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -32,7 +32,7 @@ >
{#each showPeople as person (person.id)} - onSelect(person)} circle border selectable /> + onSelect(person)} circle border selectable /> {/each}
diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte index cfd4c8f29a..2a952b8145 100644 --- a/web/src/lib/components/faces-page/people-search.svelte +++ b/web/src/lib/components/faces-page/people-search.svelte @@ -83,8 +83,8 @@ bind:name={searchName} {showLoadingSpinner} {placeholder} - on:reset={handleReset} - on:search={({ detail }) => handleSearch(detail.force ?? false)} + onReset={handleReset} + onSearch={({ force }) => handleSearch(force ?? false)} /> {:else}
diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index 7500a6faac..b5e358ec96 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -52,7 +52,7 @@
handleSelect(option)} + onSelect={handleSelect} label={$t('tag')} options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index af5c54c988..1b5368b133 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -84,7 +84,7 @@ {/if} {:else} - goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> + goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 2b8c678543..3bd462f997 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -20,7 +20,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} />
diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index f1cd0c8596..3ac8cb8d5a 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -56,7 +56,7 @@
(filters.make = detail?.value)} + onSelect={(option) => (filters.make = option?.value)} options={asComboboxOptions(makes)} placeholder={$t('search_camera_make')} selectedOption={asSelectedOption(makeFilter)} @@ -66,7 +66,7 @@
(filters.model = detail?.value)} + onSelect={(option) => (filters.model = option?.value)} options={asComboboxOptions(models)} placeholder={$t('search_camera_model')} selectedOption={asSelectedOption(modelFilter)} diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index ce265d0030..71912264ed 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -73,7 +73,7 @@
(filters.country = detail?.value)} + onSelect={(option) => (filters.country = option?.value)} options={asComboboxOptions(countries)} placeholder={$t('search_country')} selectedOption={asSelectedOption(filters.country)} @@ -83,7 +83,7 @@
(filters.state = detail?.value)} + onSelect={(option) => (filters.state = option?.value)} options={asComboboxOptions(states)} placeholder={$t('search_state')} selectedOption={asSelectedOption(filters.state)} @@ -93,7 +93,7 @@
(filters.city = detail?.value)} + onSelect={(option) => (filters.city = option?.value)} options={asComboboxOptions(cities)} placeholder={$t('search_city')} selectedOption={asSelectedOption(filters.city)} diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte index 502cd94cce..722af048a5 100644 --- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -32,14 +32,7 @@

{subtitle}

- onSelect(detail)} - /> +
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index de4bbafdd9..e6ce8f6aae 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -99,7 +99,7 @@ title={$t('theme_selection')} subtitle={$t('theme_selection_description')} bind:checked={$colorTheme.system} - on:toggle={handleToggleColorTheme} + onToggle={handleToggleColorTheme} />
@@ -119,7 +119,7 @@ title={$t('default_locale')} subtitle={$t('default_locale_description')} checked={$locale == undefined} - on:toggle={handleToggleLocaleBrowser} + onToggle={handleToggleLocaleBrowser} >

{selectedDate}

@@ -142,7 +142,7 @@ title={$t('display_original_photos')} subtitle={$t('display_original_photos_setting_description')} bind:checked={$alwaysLoadOriginalFile} - on:toggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} + onToggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} />
@@ -150,7 +150,7 @@ title={$t('video_hover_setting')} subtitle={$t('video_hover_setting_description')} bind:checked={$playVideoThumbnailOnHover} - on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} + onToggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} />
@@ -158,7 +158,7 @@ title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} - on:toggle={() => ($loopVideo = !$loopVideo)} + onToggle={() => ($loopVideo = !$loopVideo)} />
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index ee57e4c688..050e2c42f3 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -177,7 +177,7 @@ title={$t('show_in_timeline')} subtitle={$t('show_in_timeline_setting_description')} bind:checked={partner.inTimeline} - on:toggle={({ detail }) => handleShowOnTimelineChanged(partner, detail)} + onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)} /> {/if}
diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index bf0fd3c874..71f76d07c0 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -115,7 +115,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} />
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 57d09ed53a..cbdb38192e 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -470,7 +470,7 @@ {:else} {#if viewMode === ViewMode.VIEW} - goto(backUrl)}> + goto(backUrl)}> {#if isEditor} +

{#if $timelineSelected.size === 0} @@ -554,7 +554,7 @@ {/if} {#if viewMode === ViewMode.SELECT_THUMBNAIL} - (viewMode = ViewMode.VIEW)}> + (viewMode = ViewMode.VIEW)}> {$t('select_album_cover')} {/if} @@ -583,8 +583,8 @@ isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} showArchiveIcon - on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} - on:escape={handleEscape} + onSelect={({ id }) => handleUpdateThumbnail(id)} + onEscape={handleEscape} > {#if viewMode !== ViewMode.SELECT_THUMBNAIL} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2e109823ed..adbc3cfe69 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -113,7 +113,7 @@ {#if $featureFlags.loaded && $featureFlags.map}

- onViewAssets(event.detail)} /> +
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b580c4faa5..2caab9de82 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,7 +38,7 @@ {:else} - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}>

{data.partner.name}'s photos diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bb648228b9..83019d67cd 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -400,7 +400,7 @@ {:else} {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - goto(previousRoute)}> + goto(previousRoute)}> (viewMode = ViewMode.VIEW_ASSETS)}> + (viewMode = ViewMode.VIEW_ASSETS)}> {$t('select_featured_photo')} {/if} @@ -444,8 +444,8 @@ {assetInteractionStore} isSelectionMode={viewMode === ViewMode.SELECT_PERSON} singleSelect={viewMode === ViewMode.SELECT_PERSON} - on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)} - on:escape={handleEscape} + onSelect={handleSelectFeaturePhoto} + onEscape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 4649da8205..ba8ee13cc9 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -127,7 +127,7 @@ {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} - on:escape={handleEscape} + onEscape={handleEscape} withStacked > {#if $preferences.memories.enabled} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index da85eb49c8..9c6a8f9e75 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -246,7 +246,7 @@ {:else}

- goto(previousRoute)} backIcon={mdiArrowLeft}> + goto(previousRoute)} backIcon={mdiArrowLeft}>
diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 5e934143df..67e80f4703 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -52,7 +52,7 @@ }; - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}> {$t('shared_links')} From af7011164589a34f83fa896d756b5c7b1d4c5d81 Mon Sep 17 00:00:00 2001 From: Shubham Date: Sat, 21 Sep 2024 04:31:26 +0530 Subject: [PATCH 029/599] fix(mobile): Issue Selecting Many Albuns for Backup (#12784) * Update backup.provider.dart * Revert "Update backup.provider.dart" This reverts commit ac2b7acef9c4390a61a30884a05589723f572403. * Reapply "Update backup.provider.dart" This reverts commit c9fe934b3bde472a579b465fbd3b21448b819930. * dart formatting --- mobile/lib/providers/backup/backup.provider.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9329f9b1f7..0885f35f77 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -313,6 +313,9 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { + // Save to persistent storage + await _updatePersistentAlbumsSelection(); + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final Set assetsFromSelectedAlbums = {}; final Set assetsFromExcludedAlbums = {}; @@ -408,9 +411,6 @@ class BackupNotifier extends StateNotifier { selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } - - // Save to persistent storage - await _updatePersistentAlbumsSelection(); } /// Get all necessary information for calculating the available albums, From 5a1a841365a842eab345a70c420380cc00606e2e Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 21 Sep 2024 00:16:53 +0100 Subject: [PATCH 030/599] fix: rework file handling so we always explicitly create, overwrite or both (#12812) --- server/src/interfaces/storage.interface.ts | 4 +++- server/src/repositories/storage.repository.ts | 12 +++++++++-- server/src/services/metadata.service.spec.ts | 10 ++++----- server/src/services/metadata.service.ts | 2 +- server/src/services/storage.service.spec.ts | 9 ++++++-- server/src/services/storage.service.ts | 21 +++++++++++++++---- .../repositories/storage.repository.mock.ts | 4 +++- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index fec3d66dd5..321f7b8367 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -35,7 +35,9 @@ export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; - writeFile(filepath: string, buffer: Buffer): Promise; + createFile(filepath: string, buffer: Buffer): Promise; + createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; + overwriteFile(filepath: string, buffer: Buffer): Promise; realpath(filepath: string): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index c699047ce1..6fd9bb8b04 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository { return fs.stat(filepath); } - writeFile(filepath: string, buffer: Buffer) { - return fs.writeFile(filepath, buffer); + createFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'wx' }); + } + + createOrOverwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'w' }); + } + + overwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19aaa2ea1a..4eac4a4cf9 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -511,7 +511,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith( @@ -581,7 +581,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -624,7 +624,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -668,7 +668,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -716,7 +716,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.writeFile).toHaveBeenCalledTimes(0); + expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index eaa491c3ee..60a1e12a5a 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -529,7 +529,7 @@ export class MetadataService { const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); if (!existsOnDisk) { this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); + await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index b0f38554cb..930fb3c726 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -41,6 +41,11 @@ describe(StorageService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { @@ -49,13 +54,13 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { systemMock.get.mockResolvedValue({ mountFiles: true }); - storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index a8f6a76e74..15328b0c21 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -32,7 +32,7 @@ export class StorageService { for (const folder of Object.values(StorageFolder)) { if (!flags.mountFiles) { this.logger.log(`Writing initial mount file for the ${folder} folder`); - await this.verifyWriteAccess(folder); + await this.createMountFile(folder); } await this.verifyReadAccess(folder); @@ -81,17 +81,30 @@ export class StorageService { } } - private async verifyWriteAccess(folder: StorageFolder) { + private async createMountFile(folder: StorageFolder) { const { folderPath, filePath } = this.getMountFilePaths(folder); try { this.storageRepository.mkdirSync(folderPath); - await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to create ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { filePath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`)); } catch (error) { this.logger.error(`Failed to write ${filePath}: ${error}`); this.logger.error( `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, ); - throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); } } diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 5c2951e097..5226e0bb1e 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -48,7 +48,9 @@ export const newStorageRepositoryMock = (reset = true): Mocked Date: Sat, 21 Sep 2024 07:29:07 +0700 Subject: [PATCH 031/599] fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely (#12826) * fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely * log error --- mobile/lib/services/hash.service.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 2ec545453f..94d680972f 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -65,7 +65,19 @@ class HashService { if (hashes[i] != null) { continue; } - final file = await assets[i].local!.originFile; + + File? file; + + try { + file = await assets[i].local!.originFile; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", + error, + stackTrace, + ); + } + if (file == null) { final fileName = assets[i].fileName; From 7c1ea2dc73219aa06c9b5d3ee90a2a04417279d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 07:29:30 +0700 Subject: [PATCH 032/599] chore(deps): update dependency flutter to v3.24.3 (#11738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/.fvmrc | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 971587f297..ee6eaac06f 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.0" + "flutter": "3.24.3" } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7fe33c3270..aaea00d699 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1854,4 +1854,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.24.3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8787fd8565..0f75463547 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.115.0+159 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.0 + flutter: 3.24.3 dependencies: flutter: From 39ea73d654c79bdffe70d4e4804f813b049b512b Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sun, 22 Sep 2024 15:24:08 +0200 Subject: [PATCH 033/599] chore(mobile): restrict isar use via CI checks (#12840) --- mobile/analysis_options.yaml | 20 +++++++++++++++++++ mobile/lib/pages/library/favorite.page.dart | 2 +- ...e_provider.dart => favorite.provider.dart} | 0 3 files changed, 21 insertions(+), 1 deletion(-) rename mobile/lib/providers/{favorite_provider.dart => favorite.provider.dart} (100%) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2783e8f1d1..8f9d41d736 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -58,6 +58,26 @@ custom_lint: # refactor to make the providers and services testable - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_isar: + message: isar must only be used in entities and repositories + restrict: package:isar + allowed: + # required / wanted + - lib/entities/*.entity.dart + - lib/repositories/{album,asset,backup,user}.repository.dart + # acceptable exceptions for the time being + - integration_test/test_utils/general_helper.dart + - lib/main.dart + - lib/routing/router.dart + - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - test/**.dart + # refactor to make the providers and services testable + - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index 7462dc8f21..cc422f88c7 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/favorite_provider.dart'; +import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; diff --git a/mobile/lib/providers/favorite_provider.dart b/mobile/lib/providers/favorite.provider.dart similarity index 100% rename from mobile/lib/providers/favorite_provider.dart rename to mobile/lib/providers/favorite.provider.dart From 9abfa6940ca09ec3aa069b74f50a6a67c61e063e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 23 Sep 2024 06:11:23 +0200 Subject: [PATCH 034/599] docs: mobile architecture diagram (#12841) --- docs/docs/developer/architecture.mdx | 10 +- .../img/immich_mobile_architecture.drawio | 104 ++++++++++++++++++ .../img/immich_mobile_architecture.svg | 3 + 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/docs/developer/img/immich_mobile_architecture.drawio create mode 100644 docs/docs/developer/img/immich_mobile_architecture.svg diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index cf004a1119..7b5debef4c 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -3,6 +3,7 @@ sidebar_position: 1 --- import AppArchitecture from './img/app-architecture.png'; +import MobileArchitecture from './img/immich_mobile_architecture.svg'; # Architecture @@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for ### Mobile App -The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. +The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview: + + + +The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. +Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). +Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. +The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! ### Web Client diff --git a/docs/docs/developer/img/immich_mobile_architecture.drawio b/docs/docs/developer/img/immich_mobile_architecture.drawio new file mode 100644 index 0000000000..548cda0938 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.drawio @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/developer/img/immich_mobile_architecture.svg b/docs/docs/developer/img/immich_mobile_architecture.svg new file mode 100644 index 0000000000..71f28235bf --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.svg @@ -0,0 +1,3 @@ + + +
Mobile App
Mobile App
Services
Services
Repositories
Repositories
Providers
Providers
Pages
Pages
Widgets
Widgets
User
User
platform
system
platform...
on-device
database
on-device...
server
server
OpenAPI
OpenAPI
UI part
UI part
non-UI part
non-UI part
Models
Models
Entities
Entities
\ No newline at end of file From 147747de32a7362db842d95433a4ab1688eece92 Mon Sep 17 00:00:00 2001 From: kurama <52566613+zp33dy@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:40:23 +0200 Subject: [PATCH 035/599] docs: add section for Traefik Reverse Proxy (#12813) * added a section for the Traefik Proxy * minimized the configs * replaced config with a comment. * Update docs/docs/administration/reverse-proxy.md changed timeout values Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> * changed timeouts back to 10 minutes * fixed typo and set default writeTimeout 600s Leaving it at 0 may be also bad practice * removed whitespace * run `npm run format -- --check -w` --------- Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> --- docs/docs/administration/reverse-proxy.md | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 1d2488f119..c40fecbdc4 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration. ProxyPreserveHost On ``` + +### Traefik Proxy example config + +The example below is for Traefik version 3. + +The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed. + +`traefik.yaml` + +```yaml +[...] +entryPoints: + websecure: + address: :443 + # this section needs to be added + transport: + respondingTimeouts: + readTimeout: 600s + idleTimeout: 600s + writeTimeout: 600s +``` + +The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. + +`docker-compose.yml` + +```yaml +services: + immich-server: + [...] + labels: + traefik.enable: true + # increase readingTimeouts for the entrypoint used here + traefik.http.routers.immich.entrypoints: websecure + traefik.http.routers.immich.rule: Host(`immich.your-domain.com`) + traefik.http.services.immich.loadbalancer.server.port: 3001 +``` + +Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done +by adding the Traefik network to the `immich-server`. From b1cdf73a2425cf789aff1e3ab874e05d377dfe0f Mon Sep 17 00:00:00 2001 From: Nuno Antunes Date: Mon, 23 Sep 2024 08:50:18 +0100 Subject: [PATCH 036/599] feat(server): validate rating (#12855) * feat(server): validate exif rating tag * fix(server): change allowed range for rating * refactor: better readibility * docs: comments * remove log line --- server/src/services/metadata.service.spec.ts | 24 ++++++++++++++++++++ server/src/services/metadata.service.ts | 14 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 4eac4a4cf9..ad01aa5784 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1107,6 +1107,30 @@ describe(MetadataService.name, () => { }), ); }); + + it('should handle invalid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + ); + }); + + it('should handle valid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 5 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: 5, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 60a1e12a5a..bf76be0731 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -83,6 +83,18 @@ const validate = (value: T): NonNullable | null => { return value ?? null; }; +const validateRange = (value: number | undefined, min: number, max: number): NonNullable | null => { + // reutilizes the validate function + const val = validate(value); + + // check if the value is within the range + if (val == null || val < min || val > max) { + return null; + } + + return val; +}; + @Injectable() export class MetadataService { private storageCore: StorageCore; @@ -261,7 +273,7 @@ export class MetadataService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: exifTags.Rating ?? null, + rating: validateRange(exifTags.Rating, 0, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, From 0cce7ebf25b8684709ff4a270b74ab1b1f097bec Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 11:16:25 -0400 Subject: [PATCH 037/599] fix: web e2e (#12869) --- e2e/docker-compose.yml | 5 ----- e2e/playwright.config.ts | 4 +++- e2e/src/setup/docker-compose.ts | 3 ++- e2e/src/utils.ts | 3 +-- server/src/services/storage.service.ts | 5 +++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index dbb95f176d..6169a4bfa1 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,7 +22,6 @@ services: - IMMICH_METRICS=true - IMMICH_ENV=testing volumes: - - upload:/usr/src/app/upload - ./test-assets:/test-assets extra_hosts: - 'auth-server:host-gateway' @@ -44,7 +43,3 @@ services: POSTGRES_DB: immich ports: - 5435:5432 - -volumes: - model-cache: - upload: diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 55032bd364..2576a2c5c9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -53,8 +53,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'docker compose up --build -V --remove-orphans', + command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans', url: 'http://127.0.0.1:2285', + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: true, }, }); diff --git a/e2e/src/setup/docker-compose.ts b/e2e/src/setup/docker-compose.ts index 3ae87417a2..49a702e776 100644 --- a/e2e/src/setup/docker-compose.ts +++ b/e2e/src/setup/docker-compose.ts @@ -12,7 +12,8 @@ const setup = async () => { const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); - const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); + const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans'; + const child = spawn('docker', command.split(' '), { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index c67e569697..3c9d4284ce 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -156,8 +156,7 @@ export const utils = { for (const table of tables) { if (table === 'system_metadata') { - // prevent reverse geocoder from being re-initialized - sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`); + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); } else { sql.push(`DELETE FROM ${table} CASCADE;`); } diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 15328b0c21..1591149dc2 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -25,12 +25,13 @@ export class StorageService { async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + const enabled = flags.mountFiles ?? false; - this.logger.log('Verifying system mount folder checks'); + this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); // check each folder exists and is writable for (const folder of Object.values(StorageFolder)) { - if (!flags.mountFiles) { + if (!enabled) { this.logger.log(`Writing initial mount file for the ${folder} folder`); await this.createMountFile(folder); } From 9a4a320cfb82b2cf5a7e273801e4955452a4e524 Mon Sep 17 00:00:00 2001 From: Caesiumhydroxid Date: Mon, 23 Sep 2024 17:38:50 +0200 Subject: [PATCH 038/599] fix(web): Fix same key for delete and stack actions (#12865) Fix same key for delete and stack actions --- .../duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5207cf8445..e1029b7ccb 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ { key: ['s'], action: $t('view') }, { key: ['d'], action: $t('unselect_all_duplicates') }, { key: ['⇧', 'c'], action: $t('resolve_duplicates') }, - { key: ['⇧', 'c'], action: $t('stack_duplicates') }, + { key: ['⇧', 's'], action: $t('stack_duplicates') }, ], }; From a7719a94fcac4e52edefe482a7661019708fde53 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:40:25 +0200 Subject: [PATCH 039/599] fix: normalize external domain (#12831) chore: normalize external domain --- server/src/cores/system-config.core.ts | 4 ++++ .../src/services/system-config.service.spec.ts | 17 +++++++++++++++++ web/src/lib/utils.ts | 6 +----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 7c1434004a..8ed53344cc 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -120,6 +120,10 @@ export class SystemConfigCore { } } + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 409cd6a52f..7e25e0cd46 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -289,6 +289,23 @@ describe(SystemConfigService.name, () => { expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + const externalDomainTests = [ + { should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' }, + { should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + ]; + + for (const { should, externalDomain, result } of externalDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + const partialConfig = { server: { externalDomain } }; + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getConfig(); + expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; const partialConfig = ` diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 29c7552d0c..dccb03c9bf 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -257,11 +257,7 @@ export const copyToClipboard = async (secret: string) => { }; export const makeSharedLinkUrl = (externalDomain: string, key: string) => { - let url = externalDomain || window.location.origin; - if (!url.endsWith('/')) { - url += '/'; - } - return `${url}share/${key}`; + return new URL(`share/${key}`, externalDomain || window.location.origin).href; }; export const oauth = { From 9f8a7e0beac3615fd2b7b3e2f8cbb4d91448e238 Mon Sep 17 00:00:00 2001 From: jschwalbe Date: Mon, 23 Sep 2024 12:09:26 -0400 Subject: [PATCH 040/599] feat(server): sort assets randomly from the API 'api/search/metadata' endpoint by including 'order': 'rand' in the API call. (#12741) feat(server): search metadata random sort order Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/assets_api.dart | 7 +- mobile/openapi/lib/api/deprecated_api.dart | 59 ++ mobile/openapi/lib/api/search_api.dart | 47 ++ mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/random_search_dto.dart | 583 ++++++++++++++++++ open-api/immich-openapi-specs.json | 176 +++++- open-api/typescript-sdk/src/fetch-client.ts | 49 ++ server/src/controllers/asset.controller.ts | 2 + server/src/controllers/search.controller.ts | 8 + server/src/dtos/search.dto.ts | 16 +- server/src/interfaces/search.interface.ts | 1 + server/src/repositories/search.repository.ts | 7 +- server/src/services/search.service.ts | 17 + 15 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 mobile/openapi/lib/model/random_search_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 697239fa44..c8135519de 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | @@ -172,6 +173,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | *ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | @@ -379,6 +381,7 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8a1655d35a..7fa06b0487 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -192,6 +192,7 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ceba3574cd..bd1d5b8484 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -449,7 +449,10 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [num] count: @@ -482,6 +485,8 @@ class AssetsApi { ); } + /// This property was deprecated in v1.116.0 + /// /// Parameters: /// /// * [num] count: diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 96cb3c2ef0..bc8f50092a 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -71,4 +71,63 @@ class DeprecatedApi { } return null; } + + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [num] count: + Future getRandomWithHttpInfo({ num? count, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/random'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (count != null) { + queryParams.addAll(_queryParams('', 'count', count)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This property was deprecated in v1.116.0 + /// + /// Parameters: + /// + /// * [num] count: + Future?> getRandom({ num? count, }) async { + final response = await getRandomWithHttpInfo( count: count, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4b6cdfea78..3b981e0ccb 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -351,6 +351,53 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/random' operation and returns the [Response]. + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { + // ignore: prefer_const_declarations + final path = r'/search/random'; + + // ignore: prefer_final_locals + Object? postBody = randomSearchDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandom(RandomSearchDto randomSearchDto,) async { + final response = await searchRandomWithHttpInfo(randomSearchDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4976c8a75f..597a15d5b0 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -439,6 +439,8 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RandomSearchDto': + return RandomSearchDto.fromJson(value); case 'RatingsResponse': return RatingsResponse.fromJson(value); case 'RatingsUpdate': diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart new file mode 100644 index 0000000000..8dbbeb5387 --- /dev/null +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -0,0 +1,583 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RandomSearchDto { + /// Returns a new [RandomSearchDto] instance. + RandomSearchDto({ + this.city, + this.country, + this.createdAfter, + this.createdBefore, + this.deviceId, + this.isArchived, + this.isEncoded, + this.isFavorite, + this.isMotion, + this.isNotInAlbum, + this.isOffline, + this.isVisible, + this.lensModel, + this.libraryId, + this.make, + this.model, + this.page, + this.personIds = const [], + this.size, + this.state, + this.takenAfter, + this.takenBefore, + this.trashedAfter, + this.trashedBefore, + this.type, + this.updatedAfter, + this.updatedBefore, + this.withArchived = false, + this.withDeleted, + this.withExif, + this.withPeople, + this.withStacked, + }); + + String? city; + + String? country; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isEncoded; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isMotion; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isNotInAlbum; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isOffline; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isVisible; + + String? lensModel; + + String? libraryId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? make; + + String? model; + + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? page; + + List personIds; + + /// Minimum value: 1 + /// Maximum value: 1000 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? size; + + String? state; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetTypeEnum? type; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedBefore; + + bool withArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withDeleted; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withExif; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withPeople; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withStacked; + + @override + bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + other.city == city && + other.country == country && + other.createdAfter == createdAfter && + other.createdBefore == createdBefore && + other.deviceId == deviceId && + other.isArchived == isArchived && + other.isEncoded == isEncoded && + other.isFavorite == isFavorite && + other.isMotion == isMotion && + other.isNotInAlbum == isNotInAlbum && + other.isOffline == isOffline && + other.isVisible == isVisible && + other.lensModel == lensModel && + other.libraryId == libraryId && + other.make == make && + other.model == model && + other.page == page && + _deepEquality.equals(other.personIds, personIds) && + other.size == size && + other.state == state && + other.takenAfter == takenAfter && + other.takenBefore == takenBefore && + other.trashedAfter == trashedAfter && + other.trashedBefore == trashedBefore && + other.type == type && + other.updatedAfter == updatedAfter && + other.updatedBefore == updatedBefore && + other.withArchived == withArchived && + other.withDeleted == withDeleted && + other.withExif == withExif && + other.withPeople == withPeople && + other.withStacked == withStacked; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (createdAfter == null ? 0 : createdAfter!.hashCode) + + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (deviceId == null ? 0 : deviceId!.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isEncoded == null ? 0 : isEncoded!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (isMotion == null ? 0 : isMotion!.hashCode) + + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (page == null ? 0 : page!.hashCode) + + (personIds.hashCode) + + (size == null ? 0 : size!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (takenAfter == null ? 0 : takenAfter!.hashCode) + + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + + (type == null ? 0 : type!.hashCode) + + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + + (withArchived.hashCode) + + (withDeleted == null ? 0 : withDeleted!.hashCode) + + (withExif == null ? 0 : withExif!.hashCode) + + (withPeople == null ? 0 : withPeople!.hashCode) + + (withStacked == null ? 0 : withStacked!.hashCode); + + @override + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.createdAfter != null) { + json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String(); + } else { + // json[r'createdAfter'] = null; + } + if (this.createdBefore != null) { + json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String(); + } else { + // json[r'createdBefore'] = null; + } + if (this.deviceId != null) { + json[r'deviceId'] = this.deviceId; + } else { + // json[r'deviceId'] = null; + } + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isEncoded != null) { + json[r'isEncoded'] = this.isEncoded; + } else { + // json[r'isEncoded'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + if (this.isMotion != null) { + json[r'isMotion'] = this.isMotion; + } else { + // json[r'isMotion'] = null; + } + if (this.isNotInAlbum != null) { + json[r'isNotInAlbum'] = this.isNotInAlbum; + } else { + // json[r'isNotInAlbum'] = null; + } + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + if (this.page != null) { + json[r'page'] = this.page; + } else { + // json[r'page'] = null; + } + json[r'personIds'] = this.personIds; + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.takenAfter != null) { + json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); + } else { + // json[r'takenAfter'] = null; + } + if (this.takenBefore != null) { + json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String(); + } else { + // json[r'takenBefore'] = null; + } + if (this.trashedAfter != null) { + json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String(); + } else { + // json[r'trashedAfter'] = null; + } + if (this.trashedBefore != null) { + json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String(); + } else { + // json[r'trashedBefore'] = null; + } + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + if (this.updatedAfter != null) { + json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String(); + } else { + // json[r'updatedAfter'] = null; + } + if (this.updatedBefore != null) { + json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); + } else { + // json[r'updatedBefore'] = null; + } + json[r'withArchived'] = this.withArchived; + if (this.withDeleted != null) { + json[r'withDeleted'] = this.withDeleted; + } else { + // json[r'withDeleted'] = null; + } + if (this.withExif != null) { + json[r'withExif'] = this.withExif; + } else { + // json[r'withExif'] = null; + } + if (this.withPeople != null) { + json[r'withPeople'] = this.withPeople; + } else { + // json[r'withPeople'] = null; + } + if (this.withStacked != null) { + json[r'withStacked'] = this.withStacked; + } else { + // json[r'withStacked'] = null; + } + return json; + } + + /// Returns a new [RandomSearchDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RandomSearchDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return RandomSearchDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + createdAfter: mapDateTime(json, r'createdAfter', r''), + createdBefore: mapDateTime(json, r'createdBefore', r''), + deviceId: mapValueOfType(json, r'deviceId'), + isArchived: mapValueOfType(json, r'isArchived'), + isEncoded: mapValueOfType(json, r'isEncoded'), + isFavorite: mapValueOfType(json, r'isFavorite'), + isMotion: mapValueOfType(json, r'isMotion'), + isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'), + isOffline: mapValueOfType(json, r'isOffline'), + isVisible: mapValueOfType(json, r'isVisible'), + lensModel: mapValueOfType(json, r'lensModel'), + libraryId: mapValueOfType(json, r'libraryId'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + page: num.parse('${json[r'page']}'), + personIds: json[r'personIds'] is Iterable + ? (json[r'personIds'] as Iterable).cast().toList(growable: false) + : const [], + size: num.parse('${json[r'size']}'), + state: mapValueOfType(json, r'state'), + takenAfter: mapDateTime(json, r'takenAfter', r''), + takenBefore: mapDateTime(json, r'takenBefore', r''), + trashedAfter: mapDateTime(json, r'trashedAfter', r''), + trashedBefore: mapDateTime(json, r'trashedBefore', r''), + type: AssetTypeEnum.fromJson(json[r'type']), + updatedAfter: mapDateTime(json, r'updatedAfter', r''), + updatedBefore: mapDateTime(json, r'updatedBefore', r''), + withArchived: mapValueOfType(json, r'withArchived') ?? false, + withDeleted: mapValueOfType(json, r'withDeleted'), + withExif: mapValueOfType(json, r'withExif'), + withPeople: mapValueOfType(json, r'withPeople'), + withStacked: mapValueOfType(json, r'withStacked'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RandomSearchDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RandomSearchDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RandomSearchDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RandomSearchDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989da..706ff5b8fb 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1646,6 +1646,8 @@ }, "/assets/random": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.116.0", "operationId": "getRandom", "parameters": [ { @@ -1685,8 +1687,12 @@ } ], "tags": [ - "Assets" - ] + "Assets", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.116.0" + } } }, "/assets/statistics": { @@ -4677,6 +4683,48 @@ ] } }, + "/search/random": { + "post": { + "operationId": "searchRandom", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RandomSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/smart": { "post": { "operationId": "searchSmart", @@ -10454,6 +10502,130 @@ ], "type": "object" }, + "RandomSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isArchived": { + "type": "boolean" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "page": { + "minimum": 1, + "type": "number" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "size": { + "maximum": 1000, + "minimum": 1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "withArchived": { + "default": false, + "type": "boolean" + }, + "withDeleted": { + "type": "boolean" + }, + "withExif": { + "type": "boolean" + }, + "withPeople": { + "type": "boolean" + }, + "withStacked": { + "type": "boolean" + } + }, + "type": "object" + }, "RatingsResponse": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2d73bda1a..8e607f7570 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -837,6 +837,40 @@ export type PlacesResponseDto = { longitude: number; name: string; }; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + page?: number; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; export type SmartSearchDto = { city?: string | null; country?: string | null; @@ -1696,6 +1730,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -2500,6 +2537,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c6fdac1710..9d3d230657 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -31,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b8c1eeece..5b6deb2981 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9e36cfee80..ddc6c192c5 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -119,7 +119,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 6578d0a483..0ba524c00a 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,6 +116,7 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; + random?: boolean; } export interface SearchPaginationOptions { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 999e9063ef..8115c72cf6 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -73,8 +73,13 @@ export class SearchRepository implements ISearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); + + if (options.random) { + // TODO replace with complicated SQL magic after kysely migration + builder.addSelect('RANDOM() as r').orderBy('r'); + } + return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 73ace233d0..dc6e71f345 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -93,6 +94,22 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const page = dto.page ?? 1; + const size = dto.size || 250; + const { hasNextPage, items } = await this.searchRepository.searchMetadata( + { page, size }, + { + ...dto, + userIds, + random: true, + }, + ); + + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { const { machineLearning } = await this.configCore.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { From e748945b4f3ba06c5f615ad93d20b113e6ed5ee9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:22:36 -0400 Subject: [PATCH 041/599] fix(server): gracefully handle unknown jobs (#12870) --- server/src/services/job.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 03a6edf126..5ed9f32024 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -186,11 +186,16 @@ export class JobService { this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise => { const { name, data } = item; + const handler = jobHandlers[name]; + if (!handler) { + this.logger.warn(`Skipping unknown job: "${name}"`); + return; + } + const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; this.metricRepository.jobs.addToGauge(queueMetric, 1); try { - const handler = jobHandlers[name]; const status = await handler(data); const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`; this.metricRepository.jobs.addToCounter(jobMetric, 1); From 87c54d6659a73916a2c3133966b00b5b78b4e408 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:37:08 +0200 Subject: [PATCH 042/599] fix: show asset count for unassigned faces (#12871) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83019d67cd..037feaf35f 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -486,17 +486,10 @@
- {#if person.name} -

{person.name}

-

- {$t('assets_count', { values: { count: numberOfAssets } })} -

- {:else} -

{$t('add_a_name')}

-

- {$t('find_them_fast')} -

- {/if} +

{person.name || $t('add_a_name')}

+

+ {$t('assets_count', { values: { count: numberOfAssets } })} +

From 3008050e4c71ea6ea2be9f0831ea19b24fd37500 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:51:03 -0400 Subject: [PATCH 043/599] fix: remove no longer needed LD_LIBRARY_PATH (#12872) --- docker/hwaccel.transcoding.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index bd4e2a46b8..33fb7b3c06 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -51,5 +51,4 @@ services: volumes: - /usr/lib/wsl:/usr/lib/wsl environment: - - LD_LIBRARY_PATH=/usr/lib/wsl/lib - LIBVA_DRIVER_NAME=d3d12 From ad33ce5938c34edc7885b4244cef83edb09e39d5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 15:41:41 -0400 Subject: [PATCH 044/599] refactor(mobile): open api dto upgrade (#12793) --- mobile/openapi/lib/api_client.dart | 1 - .../lib/model/activity_create_dto.dart | 1 + .../lib/model/activity_response_dto.dart | 1 + .../activity_statistics_response_dto.dart | 1 + mobile/openapi/lib/model/add_users_dto.dart | 1 + .../model/admin_onboarding_update_dto.dart | 1 + .../openapi/lib/model/album_response_dto.dart | 1 + .../model/album_statistics_response_dto.dart | 1 + .../openapi/lib/model/album_user_add_dto.dart | 1 + .../lib/model/album_user_create_dto.dart | 1 + .../lib/model/album_user_response_dto.dart | 1 + .../model/all_job_status_response_dto.dart | 1 + .../openapi/lib/model/api_key_create_dto.dart | 1 + .../model/api_key_create_response_dto.dart | 1 + .../lib/model/api_key_response_dto.dart | 1 + .../openapi/lib/model/api_key_update_dto.dart | 1 + .../lib/model/asset_bulk_delete_dto.dart | 1 + .../lib/model/asset_bulk_update_dto.dart | 1 + .../model/asset_bulk_upload_check_dto.dart | 1 + .../model/asset_bulk_upload_check_item.dart | 1 + .../asset_bulk_upload_check_response_dto.dart | 1 + .../model/asset_bulk_upload_check_result.dart | 1 + .../lib/model/asset_delta_sync_dto.dart | 1 + .../model/asset_delta_sync_response_dto.dart | 1 + .../lib/model/asset_face_response_dto.dart | 1 + .../lib/model/asset_face_update_dto.dart | 1 + .../lib/model/asset_face_update_item.dart | 1 + ...sset_face_without_person_response_dto.dart | 1 + .../lib/model/asset_full_sync_dto.dart | 1 + mobile/openapi/lib/model/asset_ids_dto.dart | 1 + .../lib/model/asset_ids_response_dto.dart | 1 + mobile/openapi/lib/model/asset_jobs_dto.dart | 1 + .../lib/model/asset_media_response_dto.dart | 1 + .../openapi/lib/model/asset_response_dto.dart | 1 + .../lib/model/asset_stack_response_dto.dart | 1 + .../lib/model/asset_stats_response_dto.dart | 1 + .../lib/model/audit_deletes_response_dto.dart | 1 + mobile/openapi/lib/model/avatar_response.dart | 1 + mobile/openapi/lib/model/avatar_update.dart | 1 + .../lib/model/bulk_id_response_dto.dart | 1 + mobile/openapi/lib/model/bulk_ids_dto.dart | 1 + .../lib/model/change_password_dto.dart | 1 + .../lib/model/check_existing_assets_dto.dart | 1 + .../check_existing_assets_response_dto.dart | 1 + mobile/openapi/lib/model/clip_config.dart | 1 + .../openapi/lib/model/create_album_dto.dart | 1 + .../openapi/lib/model/create_library_dto.dart | 1 + .../create_profile_image_response_dto.dart | 1 + .../lib/model/download_archive_info.dart | 1 + .../openapi/lib/model/download_info_dto.dart | 1 + .../openapi/lib/model/download_response.dart | 1 + .../lib/model/download_response_dto.dart | 1 + mobile/openapi/lib/model/download_update.dart | 1 + .../lib/model/duplicate_detection_config.dart | 1 + .../lib/model/duplicate_response_dto.dart | 1 + .../model/email_notifications_response.dart | 1 + .../lib/model/email_notifications_update.dart | 1 + .../openapi/lib/model/exif_response_dto.dart | 1 + mobile/openapi/lib/model/face_dto.dart | 1 + .../lib/model/facial_recognition_config.dart | 1 + .../openapi/lib/model/file_checksum_dto.dart | 1 + .../lib/model/file_checksum_response_dto.dart | 1 + mobile/openapi/lib/model/file_report_dto.dart | 1 + .../lib/model/file_report_fix_dto.dart | 1 + .../lib/model/file_report_item_dto.dart | 1 + .../openapi/lib/model/folders_response.dart | 1 + mobile/openapi/lib/model/folders_update.dart | 1 + mobile/openapi/lib/model/job_command_dto.dart | 1 + mobile/openapi/lib/model/job_counts_dto.dart | 1 + mobile/openapi/lib/model/job_create_dto.dart | 1 + .../openapi/lib/model/job_settings_dto.dart | 1 + mobile/openapi/lib/model/job_status_dto.dart | 1 + .../lib/model/library_response_dto.dart | 1 + .../lib/model/library_stats_response_dto.dart | 1 + mobile/openapi/lib/model/license_key_dto.dart | 1 + .../lib/model/license_response_dto.dart | 1 + .../lib/model/login_credential_dto.dart | 1 + .../openapi/lib/model/login_response_dto.dart | 1 + .../lib/model/logout_response_dto.dart | 1 + .../lib/model/map_marker_response_dto.dart | 1 + .../map_reverse_geocode_response_dto.dart | 1 + .../openapi/lib/model/memories_response.dart | 1 + mobile/openapi/lib/model/memories_update.dart | 1 + .../openapi/lib/model/memory_create_dto.dart | 1 + .../lib/model/memory_lane_response_dto.dart | 1 + .../lib/model/memory_response_dto.dart | 1 + .../openapi/lib/model/memory_update_dto.dart | 1 + .../openapi/lib/model/merge_person_dto.dart | 1 + .../lib/model/metadata_search_dto.dart | 1 + .../model/o_auth_authorize_response_dto.dart | 1 + .../lib/model/o_auth_callback_dto.dart | 1 + .../openapi/lib/model/o_auth_config_dto.dart | 1 + mobile/openapi/lib/model/on_this_day_dto.dart | 1 + .../lib/model/partner_response_dto.dart | 1 + mobile/openapi/lib/model/people_response.dart | 1 + .../lib/model/people_response_dto.dart | 1 + mobile/openapi/lib/model/people_update.dart | 1 + .../openapi/lib/model/people_update_dto.dart | 1 + .../openapi/lib/model/people_update_item.dart | 1 + .../openapi/lib/model/person_create_dto.dart | 1 + .../lib/model/person_response_dto.dart | 1 + .../model/person_statistics_response_dto.dart | 1 + .../openapi/lib/model/person_update_dto.dart | 1 + .../model/person_with_faces_response_dto.dart | 1 + .../lib/model/places_response_dto.dart | 1 + .../openapi/lib/model/purchase_response.dart | 1 + mobile/openapi/lib/model/purchase_update.dart | 1 + .../openapi/lib/model/queue_status_dto.dart | 1 + .../openapi/lib/model/ratings_response.dart | 1 + mobile/openapi/lib/model/ratings_update.dart | 1 + .../reverse_geocoding_state_response_dto.dart | 1 + .../openapi/lib/model/scan_library_dto.dart | 1 + .../lib/model/search_album_response_dto.dart | 1 + .../lib/model/search_asset_response_dto.dart | 1 + .../lib/model/search_explore_item.dart | 1 + .../model/search_explore_response_dto.dart | 1 + .../search_facet_count_response_dto.dart | 1 + .../lib/model/search_facet_response_dto.dart | 1 + .../lib/model/search_response_dto.dart | 1 + .../lib/model/server_about_response_dto.dart | 1 + .../openapi/lib/model/server_config_dto.dart | 1 + .../lib/model/server_features_dto.dart | 1 + .../server_media_types_response_dto.dart | 1 + .../lib/model/server_ping_response.dart | 1 + .../lib/model/server_stats_response_dto.dart | 1 + .../model/server_storage_response_dto.dart | 1 + .../openapi/lib/model/server_theme_dto.dart | 1 + .../model/server_version_response_dto.dart | 1 + .../lib/model/session_response_dto.dart | 1 + .../lib/model/shared_link_create_dto.dart | 1 + .../lib/model/shared_link_edit_dto.dart | 1 + .../lib/model/shared_link_response_dto.dart | 1 + mobile/openapi/lib/model/sign_up_dto.dart | 1 + .../lib/model/smart_info_response_dto.dart | 1 + .../openapi/lib/model/smart_search_dto.dart | 1 + .../openapi/lib/model/stack_create_dto.dart | 1 + .../openapi/lib/model/stack_response_dto.dart | 1 + .../openapi/lib/model/stack_update_dto.dart | 1 + .../openapi/lib/model/system_config_dto.dart | 1 + .../lib/model/system_config_f_fmpeg_dto.dart | 1 + .../lib/model/system_config_faces_dto.dart | 1 + .../lib/model/system_config_image_dto.dart | 1 + .../lib/model/system_config_job_dto.dart | 1 + .../lib/model/system_config_library_dto.dart | 1 + .../model/system_config_library_scan_dto.dart | 1 + .../system_config_library_watch_dto.dart | 1 + .../lib/model/system_config_logging_dto.dart | 1 + .../system_config_machine_learning_dto.dart | 1 + .../lib/model/system_config_map_dto.dart | 1 + .../lib/model/system_config_metadata_dto.dart | 1 + .../system_config_new_version_check_dto.dart | 1 + .../system_config_notifications_dto.dart | 1 + .../lib/model/system_config_o_auth_dto.dart | 1 + .../system_config_password_login_dto.dart | 1 + .../system_config_reverse_geocoding_dto.dart | 1 + .../lib/model/system_config_server_dto.dart | 1 + .../lib/model/system_config_smtp_dto.dart | 1 + .../system_config_smtp_transport_dto.dart | 1 + .../system_config_storage_template_dto.dart | 1 + ...em_config_template_storage_option_dto.dart | 1 + .../lib/model/system_config_theme_dto.dart | 1 + .../lib/model/system_config_trash_dto.dart | 1 + .../lib/model/system_config_user_dto.dart | 1 + .../lib/model/tag_bulk_assets_dto.dart | 1 + .../model/tag_bulk_assets_response_dto.dart | 1 + mobile/openapi/lib/model/tag_create_dto.dart | 1 + .../openapi/lib/model/tag_response_dto.dart | 1 + mobile/openapi/lib/model/tag_update_dto.dart | 1 + mobile/openapi/lib/model/tag_upsert_dto.dart | 1 + mobile/openapi/lib/model/tags_response.dart | 1 + mobile/openapi/lib/model/tags_update.dart | 1 + .../lib/model/time_bucket_response_dto.dart | 1 + .../openapi/lib/model/trash_response_dto.dart | 1 + .../openapi/lib/model/update_album_dto.dart | 1 + .../lib/model/update_album_user_dto.dart | 1 + .../openapi/lib/model/update_asset_dto.dart | 1 + .../openapi/lib/model/update_library_dto.dart | 1 + .../openapi/lib/model/update_partner_dto.dart | 1 + .../openapi/lib/model/usage_by_user_dto.dart | 1 + .../lib/model/user_admin_create_dto.dart | 1 + .../lib/model/user_admin_delete_dto.dart | 1 + .../lib/model/user_admin_response_dto.dart | 1 + .../lib/model/user_admin_update_dto.dart | 1 + mobile/openapi/lib/model/user_license.dart | 1 + .../model/user_preferences_response_dto.dart | 1 + .../model/user_preferences_update_dto.dart | 1 + .../openapi/lib/model/user_response_dto.dart | 1 + .../openapi/lib/model/user_update_me_dto.dart | 1 + .../validate_access_token_response_dto.dart | 1 + .../lib/model/validate_library_dto.dart | 1 + ...date_library_import_path_response_dto.dart | 1 + .../model/validate_library_response_dto.dart | 1 + open-api/bin/generate-open-api.sh | 8 +- open-api/templates/mobile/api_client.mustache | 264 ------------------ .../mobile/api_client.mustache.patch | 10 - .../native/native_class.mustache | 1 + .../native/native_class.mustache.patch | 18 +- 197 files changed, 205 insertions(+), 288 deletions(-) delete mode 100644 open-api/templates/mobile/api_client.mustache delete mode 100644 open-api/templates/mobile/api_client.mustache.patch diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 597a15d5b0..e857f51e3a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,7 +166,6 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); try { switch (targetType) { case 'String': diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index b54fa2ca72..ce4b4a0176 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -78,6 +78,7 @@ class ActivityCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityCreateDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index bfffd8485b..25fb0f53f8 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -78,6 +78,7 @@ class ActivityResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 20d4696b1b..ad0b814a58 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 2daa571265..531c1ec785 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -40,6 +40,7 @@ class AddUsersDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AddUsersDto? fromJson(dynamic value) { + upgradeDto(value, "AddUsersDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 2277f0958c..298bf318a2 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AdminOnboardingUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AdminOnboardingUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index c98a95775d..547a6a70fd 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -186,6 +186,7 @@ class AlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_statistics_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart index 90dbe52016..9e19002cf1 100644 --- a/mobile/openapi/lib/model/album_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index e654a2ff5d..3f72d5c893 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -56,6 +56,7 @@ class AlbumUserAddDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserAddDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserAddDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 708acd472b..93a0661b30 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -46,6 +46,7 @@ class AlbumUserCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 8f86cf254e..bbae03fba7 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -46,6 +46,7 @@ class AlbumUserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 1ee5253c38..6ec248a638 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -118,6 +118,7 @@ class AllJobStatusResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AllJobStatusResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AllJobStatusResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index 433855c4cf..848774e9c9 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -56,6 +56,7 @@ class APIKeyCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_response_dto.dart b/mobile/openapi/lib/model/api_key_create_response_dto.dart index 93065654ac..cdaa70e37d 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -46,6 +46,7 @@ class APIKeyCreateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index b6ca86c050..fd0d91f673 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -64,6 +64,7 @@ class APIKeyResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 318f4936e1..7295d1ea1f 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -40,6 +40,7 @@ class APIKeyUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 0f6913a7f4..c4453054b1 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -56,6 +56,7 @@ class AssetBulkDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index c9b21683fb..da23d2f09d 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -148,6 +148,7 @@ class AssetBulkUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart index 55ea41b598..36c13bfdf6 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart index 16294cdae6..13dfa340fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckItem? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart index 5bfacbff57..8c3651e9fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index a016b357e7..88e46dae7d 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -88,6 +88,7 @@ class AssetBulkUploadCheckResult { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResult? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResult"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index a5ee10f33e..845aadcdcd 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -46,6 +46,7 @@ class AssetDeltaSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart index 3b14fa68cf..a64e1a2fbe 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 7a8588ce5c..c05b511649 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -102,6 +102,7 @@ class AssetFaceResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 58def49ae1..71bdde8e9a 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -40,6 +40,7 @@ class AssetFaceUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 5ea37ea4db..c2c4803259 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -46,6 +46,7 @@ class AssetFaceUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index ecfe06bd7d..8bf07e1534 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -92,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index e80638f6b0..7151094b95 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -79,6 +79,7 @@ class AssetFullSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFullSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFullSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index c8c7a69b89..b44888f396 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -40,6 +40,7 @@ class AssetIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index a642c0924c..ff63091caa 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -56,6 +56,7 @@ class AssetIdsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 16ed2644fd..0f8bfab009 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -46,6 +46,7 @@ class AssetJobsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetJobsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetJobsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart index c2801c93cc..75428ec5f6 100644 --- a/mobile/openapi/lib/model/asset_media_response_dto.dart +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -46,6 +46,7 @@ class AssetMediaResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetMediaResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMediaResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index bfb461efdc..c11dedcbfd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -293,6 +293,7 @@ class AssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart index 89d30f7810..bb4becb129 100644 --- a/mobile/openapi/lib/model/asset_stack_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -52,6 +52,7 @@ class AssetStackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index c21d7fdbff..d11ce55a5c 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -52,6 +52,7 @@ class AssetStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 690a52e811..6b1df74eb4 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -46,6 +46,7 @@ class AuditDeletesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AuditDeletesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AuditDeletesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart index edd242df4e..8ce0287565 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/avatar_response.dart @@ -40,6 +40,7 @@ class AvatarResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarResponse? fromJson(dynamic value) { + upgradeDto(value, "AvatarResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart index b92eb8dcbd..875eb138a8 100644 --- a/mobile/openapi/lib/model/avatar_update.dart +++ b/mobile/openapi/lib/model/avatar_update.dart @@ -50,6 +50,7 @@ class AvatarUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarUpdate? fromJson(dynamic value) { + upgradeDto(value, "AvatarUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index ef3cf2e0db..67a587e8d0 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -56,6 +56,7 @@ class BulkIdResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdResponseDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index 6942875f0a..6a7f8ceeec 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -40,6 +40,7 @@ class BulkIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdsDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 1074aaf74d..33b7f4a607 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -46,6 +46,7 @@ class ChangePasswordDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ChangePasswordDto? fromJson(dynamic value) { + upgradeDto(value, "ChangePasswordDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index 49ef36cc09..42ce6d5c3e 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -46,6 +46,7 @@ class CheckExistingAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart index d8b0f43a6d..ad93578ebc 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 6e95c15fbf..b500d20f2e 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -46,6 +46,7 @@ class CLIPConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CLIPConfig? fromJson(dynamic value) { + upgradeDto(value, "CLIPConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index fa28b782ac..ff8c1df647 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -68,6 +68,7 @@ class CreateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "CreateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 65ceec8e8a..bffa5f4279 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -68,6 +68,7 @@ class CreateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "CreateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_profile_image_response_dto.dart b/mobile/openapi/lib/model/create_profile_image_response_dto.dart index 86624ed06b..ee98142e86 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -52,6 +52,7 @@ class CreateProfileImageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateProfileImageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CreateProfileImageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index e324850bdc..5f3fd1a8c1 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -46,6 +46,7 @@ class DownloadArchiveInfo { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadArchiveInfo? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveInfo"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 4c38769010..6f4777975c 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -89,6 +89,7 @@ class DownloadInfoDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadInfoDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadInfoDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 25c5159a8b..041da44b71 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -46,6 +46,7 @@ class DownloadResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponse? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index f32cba9253..5c6bd11266 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -46,6 +46,7 @@ class DownloadResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 2c3839a687..8df825a922 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -67,6 +67,7 @@ class DownloadUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadUpdate? fromJson(dynamic value) { + upgradeDto(value, "DownloadUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart index 0bc6091784..e4fc352028 100644 --- a/mobile/openapi/lib/model/duplicate_detection_config.dart +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -48,6 +48,7 @@ class DuplicateDetectionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateDetectionConfig? fromJson(dynamic value) { + upgradeDto(value, "DuplicateDetectionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart index b93ecfe5f5..6ac7c46871 100644 --- a/mobile/openapi/lib/model/duplicate_response_dto.dart +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -46,6 +46,7 @@ class DuplicateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart index cef92957c6..d6dcfb9273 100644 --- a/mobile/openapi/lib/model/email_notifications_response.dart +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -52,6 +52,7 @@ class EmailNotificationsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsResponse? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart index dcd1ec4322..dad0a52fde 100644 --- a/mobile/openapi/lib/model/email_notifications_update.dart +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -82,6 +82,7 @@ class EmailNotificationsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsUpdate? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fa..17397b2081 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -254,6 +254,7 @@ class ExifResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ExifResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ExifResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4fcc86debf..c84a518b8c 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -40,6 +40,7 @@ class FaceDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FaceDto? fromJson(dynamic value) { + upgradeDto(value, "FaceDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 52400fd7e1..4acfd4e20f 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -69,6 +69,7 @@ class FacialRecognitionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FacialRecognitionConfig? fromJson(dynamic value) { + upgradeDto(value, "FacialRecognitionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index c7e8aa1da6..7dc9ccdf2f 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -40,6 +40,7 @@ class FileChecksumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index d4bae3c273..7b963c8bd5 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -46,6 +46,7 @@ class FileChecksumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 422215ff6c..3dc892e5e7 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -46,6 +46,7 @@ class FileReportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index cf09242b0f..d46cdeb4b7 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -40,6 +40,7 @@ class FileReportFixDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportFixDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportFixDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index 5255005daa..1ef08c2b48 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -74,6 +74,7 @@ class FileReportItemDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportItemDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportItemDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart index 5bfc4c793d..248b64b054 100644 --- a/mobile/openapi/lib/model/folders_response.dart +++ b/mobile/openapi/lib/model/folders_response.dart @@ -46,6 +46,7 @@ class FoldersResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersResponse? fromJson(dynamic value) { + upgradeDto(value, "FoldersResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart index 088c98a4d8..0234717754 100644 --- a/mobile/openapi/lib/model/folders_update.dart +++ b/mobile/openapi/lib/model/folders_update.dart @@ -66,6 +66,7 @@ class FoldersUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersUpdate? fromJson(dynamic value) { + upgradeDto(value, "FoldersUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 5c56715644..649e0128a7 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -46,6 +46,7 @@ class JobCommandDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCommandDto? fromJson(dynamic value) { + upgradeDto(value, "JobCommandDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index cf1d0b457d..afc90d1084 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -70,6 +70,7 @@ class JobCountsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCountsDto? fromJson(dynamic value) { + upgradeDto(value, "JobCountsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart index a4734791bb..fe6743cba0 100644 --- a/mobile/openapi/lib/model/job_create_dto.dart +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -40,6 +40,7 @@ class JobCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "JobCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index 9c59d503ca..af354bef9e 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -41,6 +41,7 @@ class JobSettingsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "JobSettingsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index fd925bd53a..18fab8dfb3 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -46,6 +46,7 @@ class JobStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobStatusDto? fromJson(dynamic value) { + upgradeDto(value, "JobStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index e27b489104..3cf1248508 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -92,6 +92,7 @@ class LibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 8cfb292855..afe67da31a 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -58,6 +58,7 @@ class LibraryStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart index aece85f81e..d27d579bb4 100644 --- a/mobile/openapi/lib/model/license_key_dto.dart +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -46,6 +46,7 @@ class LicenseKeyDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseKeyDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseKeyDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart index f83668af57..6d3009433f 100644 --- a/mobile/openapi/lib/model/license_response_dto.dart +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -52,6 +52,7 @@ class LicenseResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index ac2f511691..7e892ab5fb 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -46,6 +46,7 @@ class LoginCredentialDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginCredentialDto? fromJson(dynamic value) { + upgradeDto(value, "LoginCredentialDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index 6a0eb2355c..dbc82d07ba 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -76,6 +76,7 @@ class LoginResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LoginResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index ca1e8d23bb..aa94904e2a 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -46,6 +46,7 @@ class LogoutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LogoutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LogoutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index ca1ec3c8a1..74ac51a271 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -82,6 +82,7 @@ class MapMarkerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapMarkerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapMarkerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart index ac99dd91a9..6d8757d39f 100644 --- a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -64,6 +64,7 @@ class MapReverseGeocodeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapReverseGeocodeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index e215a66a03..b9f8b5d8b1 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -40,6 +40,7 @@ class MemoriesResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesResponse? fromJson(dynamic value) { + upgradeDto(value, "MemoriesResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index d309491361..71efd71ae7 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -50,6 +50,7 @@ class MemoriesUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesUpdate? fromJson(dynamic value) { + upgradeDto(value, "MemoriesUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 2efdf88936..15985f2f1c 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -90,6 +90,7 @@ class MemoryCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryCreateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 4abe607381..27248d05c1 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -46,6 +46,7 @@ class MemoryLaneResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryLaneResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryLaneResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index f794be53cd..652c993536 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -120,6 +120,7 @@ class MemoryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index 318f4b42ad..e750f9faad 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -82,6 +82,7 @@ class MemoryUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index ea23042e2c..fd225276b6 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -40,6 +40,7 @@ class MergePersonDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MergePersonDto? fromJson(dynamic value) { + upgradeDto(value, "MergePersonDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index fabf7a2610..0aef1f623e 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -637,6 +637,7 @@ class MetadataSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MetadataSearchDto? fromJson(dynamic value) { + upgradeDto(value, "MetadataSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart index ffd017f816..869c3be753 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -40,6 +40,7 @@ class OAuthAuthorizeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthAuthorizeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 89ad0f60b0..d0b98d5c6f 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -40,6 +40,7 @@ class OAuthCallbackDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthCallbackDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthCallbackDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index 7d76758864..86c79b4e04 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -40,6 +40,7 @@ class OAuthConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthConfigDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index be170caf85..bfcc4fd630 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -41,6 +41,7 @@ class OnThisDayDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OnThisDayDto? fromJson(dynamic value) { + upgradeDto(value, "OnThisDayDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 375303c94a..f61df86b42 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -86,6 +86,7 @@ class PartnerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PartnerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PartnerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart index e12f86eeab..1312c73874 100644 --- a/mobile/openapi/lib/model/people_response.dart +++ b/mobile/openapi/lib/model/people_response.dart @@ -46,6 +46,7 @@ class PeopleResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponse? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 87e8c34fb0..49f0e85aad 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -69,6 +69,7 @@ class PeopleResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart index 7803e62970..fb4eeeb434 100644 --- a/mobile/openapi/lib/model/people_update.dart +++ b/mobile/openapi/lib/model/people_update.dart @@ -66,6 +66,7 @@ class PeopleUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdate? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index 9fcfdc8761..f771084f75 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -40,6 +40,7 @@ class PeopleUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 8af0a8b11a..042e4fa36f 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -103,6 +103,7 @@ class PeopleUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 9889328dee..36bd6dfee9 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -79,6 +79,7 @@ class PersonCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonCreateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 50ee28f0af..0b36fcde3b 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -85,6 +85,7 @@ class PersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 929fbc29d2..d9f84e9f4c 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -40,6 +40,7 @@ class PersonStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 1af03890a2..51a7ea25d0 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -96,6 +96,7 @@ class PersonUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index af2e7101c3..b14bad7895 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -91,6 +91,7 @@ class PersonWithFacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonWithFacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonWithFacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index d3e1fc449b..4f77788263 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -84,6 +84,7 @@ class PlacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PlacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PlacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_response.dart b/mobile/openapi/lib/model/purchase_response.dart index 284d899528..a117206977 100644 --- a/mobile/openapi/lib/model/purchase_response.dart +++ b/mobile/openapi/lib/model/purchase_response.dart @@ -46,6 +46,7 @@ class PurchaseResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseResponse? fromJson(dynamic value) { + upgradeDto(value, "PurchaseResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_update.dart b/mobile/openapi/lib/model/purchase_update.dart index ca0a27e3bc..69057e6c55 100644 --- a/mobile/openapi/lib/model/purchase_update.dart +++ b/mobile/openapi/lib/model/purchase_update.dart @@ -66,6 +66,7 @@ class PurchaseUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseUpdate? fromJson(dynamic value) { + upgradeDto(value, "PurchaseUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 7f7d310f6f..77591affe2 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -46,6 +46,7 @@ class QueueStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static QueueStatusDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_response.dart b/mobile/openapi/lib/model/ratings_response.dart index c8791aa91a..8e1951277a 100644 --- a/mobile/openapi/lib/model/ratings_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -40,6 +40,7 @@ class RatingsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsResponse? fromJson(dynamic value) { + upgradeDto(value, "RatingsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_update.dart b/mobile/openapi/lib/model/ratings_update.dart index bde51bad1b..5d9f9a655f 100644 --- a/mobile/openapi/lib/model/ratings_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -50,6 +50,7 @@ class RatingsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsUpdate? fromJson(dynamic value) { + upgradeDto(value, "RatingsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart index eb414be984..5b3648b46b 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -54,6 +54,7 @@ class ReverseGeocodingStateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ReverseGeocodingStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ReverseGeocodingStateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart index 1b31aaaf01..8ff978be05 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -66,6 +66,7 @@ class ScanLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ScanLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ScanLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 46ce5273ac..e9b47e85ec 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -58,6 +58,7 @@ class SearchAlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 21ddbbb213..3d214e61d9 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -68,6 +68,7 @@ class SearchAssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 951fdd1bc8..d44b2cd704 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -46,6 +46,7 @@ class SearchExploreItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreItem? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index 5bc601de9e..3b5d4f9849 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -46,6 +46,7 @@ class SearchExploreResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart index b40710e525..f8eee84485 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetCountResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetCountResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetCountResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 0784921c6b..aeec873c8d 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 9b2b7fd3cf..ca742ae35c 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -46,6 +46,7 @@ class SearchResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 9c71d1fccd..1ab51a80f1 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -276,6 +276,7 @@ class ServerAboutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerAboutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerAboutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 47cc52fb2c..c45ed32ac0 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -76,6 +76,7 @@ class ServerConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerConfigDto? fromJson(dynamic value) { + upgradeDto(value, "ServerConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 0a7d8a4b47..5149c3796a 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -118,6 +118,7 @@ class ServerFeaturesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerFeaturesDto? fromJson(dynamic value) { + upgradeDto(value, "ServerFeaturesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_media_types_response_dto.dart b/mobile/openapi/lib/model/server_media_types_response_dto.dart index 35ddef1956..506cbb44b4 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -52,6 +52,7 @@ class ServerMediaTypesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerMediaTypesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerMediaTypesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index e23dc15c61..621ebfa294 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -40,6 +40,7 @@ class ServerPingResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerPingResponse? fromJson(dynamic value) { + upgradeDto(value, "ServerPingResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 6996e49aa5..654a34ee6b 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -58,6 +58,7 @@ class ServerStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_storage_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart index 89d97d32ea..8d12e77834 100644 --- a/mobile/openapi/lib/model/server_storage_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -76,6 +76,7 @@ class ServerStorageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStorageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStorageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index 65b9b9163e..69e1b2d2c8 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -40,6 +40,7 @@ class ServerThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerThemeDto? fromJson(dynamic value) { + upgradeDto(value, "ServerThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index e507f3372a..751347fabd 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -52,6 +52,7 @@ class ServerVersionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerVersionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 82673b3874..92e2dc6067 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -70,6 +70,7 @@ class SessionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SessionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 623bc3125f..bc96b31fd2 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -132,6 +132,7 @@ class SharedLinkCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 2369c85db1..a394ba9b3b 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -141,6 +141,7 @@ class SharedLinkEditDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkEditDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkEditDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 018a1a51de..9cc8b3ac80 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -144,6 +144,7 @@ class SharedLinkResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index 772749fdba..7e0ff4045c 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -52,6 +52,7 @@ class SignUpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SignUpDto? fromJson(dynamic value) { + upgradeDto(value, "SignUpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 52e7c108b8..4631eccf2c 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -54,6 +54,7 @@ class SmartInfoResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartInfoResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SmartInfoResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 2a42b75768..4e1408cafa 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -467,6 +467,7 @@ class SmartSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartSearchDto? fromJson(dynamic value) { + upgradeDto(value, "SmartSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart index 9b37bc6e2e..cb51081eb1 100644 --- a/mobile/openapi/lib/model/stack_create_dto.dart +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -41,6 +41,7 @@ class StackCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackCreateDto? fromJson(dynamic value) { + upgradeDto(value, "StackCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart index 3d0aaf91d1..b6cb747caf 100644 --- a/mobile/openapi/lib/model/stack_response_dto.dart +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -52,6 +52,7 @@ class StackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "StackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart index 0e97127210..0101499edf 100644 --- a/mobile/openapi/lib/model/stack_update_dto.dart +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -50,6 +50,7 @@ class StackUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "StackUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index aff8062c8a..5306370d2d 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -142,6 +142,7 @@ class SystemConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index a75a77c669..73f7d35aec 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -175,6 +175,7 @@ class SystemConfigFFmpegDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFFmpegDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFFmpegDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_faces_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart index 980e494fb7..4e18eb8de2 100644 --- a/mobile/openapi/lib/model/system_config_faces_dto.dart +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -40,6 +40,7 @@ class SystemConfigFacesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFacesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFacesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 388949c759..681a8c00c3 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -80,6 +80,7 @@ class SystemConfigImageDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigImageDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 1bc0f6b29c..c0fed5cccc 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -100,6 +100,7 @@ class SystemConfigJobDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigJobDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigJobDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f55e33e80..e728b0bf20 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart index 31df272594..6a6558b4b3 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryScanDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryScanDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryScanDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 9d152f366a..1a1f5d7126 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -40,6 +40,7 @@ class SystemConfigLibraryWatchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryWatchDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryWatchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 60c0be3d2c..f025221eff 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLoggingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLoggingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLoggingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index 3923bacad4..d665f0bfa5 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -64,6 +64,7 @@ class SystemConfigMachineLearningDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMachineLearningDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMachineLearningDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index 6631885182..d53d5711db 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -52,6 +52,7 @@ class SystemConfigMapDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMapDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMapDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart index 60ca35c835..3c32fc551d 100644 --- a/mobile/openapi/lib/model/system_config_metadata_dto.dart +++ b/mobile/openapi/lib/model/system_config_metadata_dto.dart @@ -40,6 +40,7 @@ class SystemConfigMetadataDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMetadataDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index c7b8c98695..c63d2abc1b 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNewVersionCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNewVersionCheckDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNewVersionCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 22f08b3ab4..35d3d31833 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNotificationsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNotificationsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNotificationsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 6ebbe8d25c..9125bb7bba 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -125,6 +125,7 @@ class SystemConfigOAuthDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigOAuthDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigOAuthDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart index 61896a890c..69c8942bb6 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -40,6 +40,7 @@ class SystemConfigPasswordLoginDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigPasswordLoginDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 2eb586cac6..6c1673d46c 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -40,6 +40,7 @@ class SystemConfigReverseGeocodingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigReverseGeocodingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigReverseGeocodingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index ccb48ee61d..b1b92c9515 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -46,6 +46,7 @@ class SystemConfigServerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigServerDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigServerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index 6588d244ee..fcde49cf35 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -58,6 +58,7 @@ class SystemConfigSmtpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index 63dfdca4cf..bdaaa426c5 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -66,6 +66,7 @@ class SystemConfigSmtpTransportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpTransportDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpTransportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_storage_template_dto.dart b/mobile/openapi/lib/model/system_config_storage_template_dto.dart index 13323aebda..596aafc195 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -52,6 +52,7 @@ class SystemConfigStorageTemplateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigStorageTemplateDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigStorageTemplateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart index 82e0a6f747..f8586d344c 100644 --- a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart +++ b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart @@ -82,6 +82,7 @@ class SystemConfigTemplateStorageOptionDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateStorageOptionDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index 2f7f4d2f3b..a97c2cf84c 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -40,6 +40,7 @@ class SystemConfigThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigThemeDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 336019fde4..51b39e9a55 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -47,6 +47,7 @@ class SystemConfigTrashDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTrashDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTrashDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index c466374460..8e6bd3c9c3 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -41,6 +41,7 @@ class SystemConfigUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigUserDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart index c11cb66ce0..26a575e193 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -46,6 +46,7 @@ class TagBulkAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart index d4dcb91d8c..009f26bfe4 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -40,6 +40,7 @@ class TagBulkAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart index dd7e537a0a..9a5171074d 100644 --- a/mobile/openapi/lib/model/tag_create_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -66,6 +66,7 @@ class TagCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagCreateDto? fromJson(dynamic value) { + upgradeDto(value, "TagCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 1d1a88c3cf..cd684b163a 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -96,6 +96,7 @@ class TagResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_update_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart index 661f65896e..ab1adb127b 100644 --- a/mobile/openapi/lib/model/tag_update_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -44,6 +44,7 @@ class TagUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart index 941d25b6ae..d60a00f466 100644 --- a/mobile/openapi/lib/model/tag_upsert_dto.dart +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -40,6 +40,7 @@ class TagUpsertDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpsertDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart index 3a5ea3b20b..2470edf979 100644 --- a/mobile/openapi/lib/model/tags_response.dart +++ b/mobile/openapi/lib/model/tags_response.dart @@ -46,6 +46,7 @@ class TagsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsResponse? fromJson(dynamic value) { + upgradeDto(value, "TagsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart index 8355b00a00..d992369140 100644 --- a/mobile/openapi/lib/model/tags_update.dart +++ b/mobile/openapi/lib/model/tags_update.dart @@ -66,6 +66,7 @@ class TagsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsUpdate? fromJson(dynamic value) { + upgradeDto(value, "TagsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 2c86a56b3c..56044b27a8 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -46,6 +46,7 @@ class TimeBucketResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TimeBucketResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/trash_response_dto.dart b/mobile/openapi/lib/model/trash_response_dto.dart index 52a05ff6d4..2df154d06c 100644 --- a/mobile/openapi/lib/model/trash_response_dto.dart +++ b/mobile/openapi/lib/model/trash_response_dto.dart @@ -40,6 +40,7 @@ class TrashResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TrashResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TrashResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index f9c9762887..8353dba14e 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -114,6 +114,7 @@ class UpdateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index f77223acf5..43218cae6e 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -40,6 +40,7 @@ class UpdateAlbumUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumUserDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9aa413d242..9ebce5fd92 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -158,6 +158,7 @@ class UpdateAssetDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAssetDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAssetDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index 85847c0ddf..b85df40172 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -62,6 +62,7 @@ class UpdateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index f695f99535..3af3c83ad1 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -40,6 +40,7 @@ class UpdatePartnerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdatePartnerDto? fromJson(dynamic value) { + upgradeDto(value, "UpdatePartnerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index 0bbbba00bb..e6f9216d74 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -74,6 +74,7 @@ class UsageByUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UsageByUserDto? fromJson(dynamic value) { + upgradeDto(value, "UsageByUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index db514a1d57..f2709be57b 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -105,6 +105,7 @@ class UserAdminCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminCreateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_delete_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart index 7778b15775..2cf68ad7b2 100644 --- a/mobile/openapi/lib/model/user_admin_delete_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -50,6 +50,7 @@ class UserAdminDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 461596b7bf..e5ae8e1d4e 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -156,6 +156,7 @@ class UserAdminResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index dd0db767fe..6c6f73ae8e 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -119,6 +119,7 @@ class UserAdminUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart index c7abb085f2..9bed8d5c43 100644 --- a/mobile/openapi/lib/model/user_license.dart +++ b/mobile/openapi/lib/model/user_license.dart @@ -52,6 +52,7 @@ class UserLicense { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserLicense? fromJson(dynamic value) { + upgradeDto(value, "UserLicense"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index d3927df8d7..23d9ea84ec 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -88,6 +88,7 @@ class UserPreferencesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 2841c2f572..208dbf6860 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -178,6 +178,7 @@ class UserPreferencesUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 282a5a40dc..a02da29948 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -70,6 +70,7 @@ class UserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 2d665fc784..8f3f4df37a 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -82,6 +82,7 @@ class UserUpdateMeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserUpdateMeDto? fromJson(dynamic value) { + upgradeDto(value, "UserUpdateMeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_access_token_response_dto.dart b/mobile/openapi/lib/model/validate_access_token_response_dto.dart index e970f7e840..5e36efcfed 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -40,6 +40,7 @@ class ValidateAccessTokenResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateAccessTokenResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateAccessTokenResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 05e122b1a1..08199e3aa6 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -46,6 +46,7 @@ class ValidateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 23aac0b742..11fbbd74c2 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -62,6 +62,7 @@ class ValidateLibraryImportPathResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryImportPathResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index b213f9ba98..e0dc2a2d14 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -40,6 +40,7 @@ class ValidateLibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 2ca0463046..bf8b24b557 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,11 +9,7 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} -{{>part_of}} -class ApiClient { - ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); - - final String basePath; - final Authentication? authentication; - - var _client = Client(); - final _defaultHeaderMap = {}; - - /// Returns the current HTTP [Client] instance to use in this class. - /// - /// The return value is guaranteed to never be null. - Client get client => _client; - - /// Requests to use a new HTTP [Client] in this class. - set client(Client newClient) { - _client = newClient; - } - - Map get defaultHeaderMap => _defaultHeaderMap; - - void addDefaultHeader(String key, String value) { - _defaultHeaderMap[key] = value; - } - - // We don't use a Map for queryParams. - // If collectionFormat is 'multi', a key might appear multiple times. - Future invokeAPI( - String path, - String method, - List queryParams, - Object? body, - Map headerParams, - Map formParams, - String? contentType, - ) async { - await authentication?.applyToParams(queryParams, headerParams); - - headerParams.addAll(_defaultHeaderMap); - if (contentType != null) { - headerParams['Content-Type'] = contentType; - } - - final urlEncodedQueryParams = queryParams.map((param) => '$param'); - final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; - final uri = Uri.parse('$basePath$path$queryString'); - - try { - // Special case for uploading a single file which isn't a 'multipart/form-data'. - if ( - body is MultipartFile && (contentType == null || - !contentType.toLowerCase().startsWith('multipart/form-data')) - ) { - final request = StreamedRequest(method, uri); - request.headers.addAll(headerParams); - request.contentLength = body.length; - body.finalize().listen( - request.sink.add, - onDone: request.sink.close, - // ignore: avoid_types_on_closure_parameters - onError: (Object error, StackTrace trace) => request.sink.close(), - cancelOnError: true, - ); - final response = await _client.send(request); - return Response.fromStream(response); - } - - if (body is MultipartRequest) { - final request = MultipartRequest(method, uri); - request.fields.addAll(body.fields); - request.files.addAll(body.files); - request.headers.addAll(body.headers); - request.headers.addAll(headerParams); - final response = await _client.send(request); - return Response.fromStream(response); - } - - final msgBody = contentType == 'application/x-www-form-urlencoded' - ? formParams - : await serializeAsync(body); - final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; - - switch(method) { - case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); - case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); - case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); - case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); - } - } on SocketException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Socket operation failed: $method $path', - error, - trace, - ); - } on TlsException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'TLS/SSL communication failed: $method $path', - error, - trace, - ); - } on IOException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'I/O operation failed: $method $path', - error, - trace, - ); - } on ClientException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'HTTP connection failed: $method $path', - error, - trace, - ); - } on Exception catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Exception occurred: $method $path', - error, - trace, - ); - } - - throw ApiException( - HttpStatus.badRequest, - 'Invalid HTTP operation: $method $path', - ); - } -{{#native_serialization}} - - Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => - // ignore: deprecated_member_use_from_same_package - deserialize(value, targetType, growable: growable); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') - dynamic deserialize(String value, String targetType, {bool growable = false,}) { - // Remove all spaces. Necessary for regular expressions as well. - targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? value - : fromJson(json.decode(value), targetType, growable: growable); - } -{{/native_serialization}} - - // ignore: deprecated_member_use_from_same_package - Future serializeAsync(Object? value) async => serialize(value); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') - String serialize(Object? value) => value == null ? '' : json.encode(value); - -{{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': - return value is String ? value : value.toString(); - case 'int': - return value is int ? value : int.parse('$value'); - case 'double': - return value is double ? value : double.parse('$value'); - case 'bool': - if (value is bool) { - return value; - } - final valueString = '$value'.toLowerCase(); - return valueString == 'true' || valueString == '1'; - case 'DateTime': - return value is DateTime ? value : DateTime.tryParse(value); - {{#models}} - {{#model}} - case '{{{classname}}}': - {{#isEnum}} - {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} - {{/isEnum}} - {{^isEnum}} - return {{{classname}}}.fromJson(value); - {{/isEnum}} - {{/model}} - {{/models}} - default: - dynamic match; - if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toList(growable: growable); - } - if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toSet(); - } - if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { - return Map.fromIterables( - value.keys.cast(), - value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), - ); - } - } - } on Exception catch (error, trace) { - throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); - } - throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); - } -{{/native_serialization}} -} -{{#native_serialization}} - -/// Primarily intended for use in an isolate. -class DeserializationMessage { - const DeserializationMessage({ - required this.json, - required this.targetType, - this.growable = false, - }); - - /// The JSON value to deserialize. - final String json; - - /// Target type to deserialize to. - final String targetType; - - /// Whether to make deserialized lists or maps growable. - final bool growable; -} - -/// Primarily intended for use in an isolate. -Future decodeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : json.decode(message.json); -} - -/// Primarily intended for use in an isolate. -Future deserializeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : ApiClient.fromJson( - json.decode(message.json), - targetType, - growable: message.growable, - ); -} -{{/native_serialization}} - -/// Primarily intended for use in an isolate. -Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch deleted file mode 100644 index 3805cd8f79..0000000000 --- a/open-api/templates/mobile/api_client.mustache.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 -+++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 -@@ -159,6 +159,7 @@ - {{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { -+ upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 254843e00e..9a7b1439b1 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -111,6 +111,7 @@ class {{{classname}}} { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static {{{classname}}}? fromJson(dynamic value) { + upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 02e07f933a..4ba6594966 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2023-08-31 23:09:59.584269162 +0200 -+++ native_class1.mustache 2023-08-31 22:59:53.633083270 +0200 +--- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 ++++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -17,10 +17,14 @@ } {{/defaultValue}} {{/required}} -@@ -114,17 +114,6 @@ +@@ -111,20 +111,10 @@ + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { ++ upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); - + - // Ensure that the map contains the required keys. - // Note 1: the values aren't checked for validity beyond being non-null. - // Note 2: this code is stripped in release mode! @@ -35,9 +39,9 @@ return {{{classname}}}( {{#vars}} {{#isDateTime}} -@@ -215,6 +204,10 @@ +@@ -215,6 +205,10 @@ ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), @@ -46,7 +50,7 @@ {{^isNumber}} {{^isEnum}} {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, -@@ -223,6 +216,7 @@ +@@ -223,6 +217,7 @@ {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isEnum}} {{/isNumber}} From e41785b1a1e6591c7b385f97d45d8417f8c451ef Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:08:01 +0200 Subject: [PATCH 045/599] fix: open api (#12878) --- mobile/openapi/lib/model/random_search_dto.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 8dbbeb5387..419cb451e2 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -493,6 +493,7 @@ class RandomSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RandomSearchDto? fromJson(dynamic value) { + upgradeDto(value, "RandomSearchDto"); if (value is Map) { final json = value.cast(); From bcd416477b0d9dd76f3a2f11547f220354c0f9a0 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 23 Sep 2024 21:30:23 +0100 Subject: [PATCH 046/599] feat: serve map tile styles from tiles.immich.cloud (#12858) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- e2e/package-lock.json | 6 +- e2e/src/api/specs/map.e2e-spec.ts | 65 +- e2e/src/api/specs/server-info.e2e-spec.ts | 2 + e2e/src/api/specs/server.e2e-spec.ts | 2 + mobile/.vscode/settings.json | 2 +- .../server_info/server_config.model.dart | 10 +- mobile/lib/pages/search/map/map.page.dart | 15 +- .../lib/providers/map/map_state.provider.dart | 75 +- .../lib/providers/server_info.provider.dart | 3 + mobile/lib/utils/openapi_patching.dart | 13 + mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/map_api.dart | 56 - mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/map_theme.dart | 85 -- .../openapi/lib/model/server_config_dto.dart | 18 +- open-api/immich-openapi-specs.json | 66 +- open-api/typescript-sdk/src/fetch-client.ts | 20 +- server/package-lock.json | 1122 +++++++++-------- server/src/config.ts | 4 +- server/src/controllers/map.controller.ts | 7 - server/src/dtos/server.dto.ts | 2 + server/src/dtos/system-config.dto.ts | 6 +- server/src/services/map.service.ts | 11 - server/src/services/server.service.spec.ts | 2 + server/src/services/server.service.ts | 2 + .../services/system-config.service.spec.ts | 4 +- .../shared-components/map/map.svelte | 16 +- web/src/lib/stores/server-config.store.ts | 2 + 30 files changed, 676 insertions(+), 948 deletions(-) delete mode 100644 mobile/openapi/lib/model/map_theme.dart diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 865f154d6b..ab4fd53fbf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -5016,9 +5016,9 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathe": { diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 343a7c91d0..da5f779cff 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,8 +1,7 @@ -import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); - asset = await utils.createAsset(admin.accessToken); - const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; utils.resetEvents(); const uploadFile = async (input: string) => { @@ -103,63 +97,6 @@ describe('/map', () => { }); }); - describe('GET /map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - }); - describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 571d98cda7..1ef8d8602a 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -128,6 +128,8 @@ describe('/server-info', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index b19e6d85c4..3133460ada 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -134,6 +134,8 @@ describe('/server', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index aa43dab3fb..ceaf9a6ab8 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.24.0", + "dart.flutterSdkPath": ".fvm/versions/3.24.3", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 8936939135..f07ffde522 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,11 +4,15 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String mapDarkStyleUrl; + final String mapLightStyleUrl; const ServerConfig({ required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, }); ServerConfig copyWith({ @@ -20,6 +24,8 @@ class ServerConfig { trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + mapDarkStyleUrl: mapDarkStyleUrl, + mapLightStyleUrl: mapLightStyleUrl, ); } @@ -30,7 +36,9 @@ class ServerConfig { ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain; + externalDomain = dto.externalDomain, + mapDarkStyleUrl = dto.mapDarkStyleUrl, + mapLightStyleUrl = dto.mapLightStyleUrl; @override bool operator ==(covariant ServerConfig other) { diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index d226ea55a3..3be7e9b3e5 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: MediaQuery.of(context).padding.bottom + 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 6d1630bba2..189a23cd0a 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -1,28 +1,23 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_state.provider.g.dart'; @Riverpod(keepAlive: true) class MapStateNotifier extends _$MapStateNotifier { - final _log = Logger("MapStateNotifier"); - @override MapState build() { final appSettingsProvider = ref.read(appSettingsServiceProvider); - // Fetch and save the Style JSONs - loadStyles(); + final lightStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl; + final darkStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl; + return MapState( themeMode: ThemeMode.values[ appSettingsProvider.getSetting(AppSettingsEnum.mapThemeMode)], @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier { appSettingsProvider.getSetting(AppSettingsEnum.mapwithPartners), relativeTime: appSettingsProvider.getSetting(AppSettingsEnum.mapRelativeDate), + lightStyleFetched: AsyncData(lightStyleUrl), + darkStyleFetched: AsyncData(darkStyleUrl), ); } - void loadStyles() async { - final documents = (await getApplicationDocumentsDirectory()).path; - - // Set to loading - state = state.copyWith(lightStyleFetched: const AsyncLoading()); - - // Fetch and save light theme - final lightResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.light); - - if (lightResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), - ); - _log.severe( - "Cannot fetch map light style", - lightResponse.toLoggerString(), - ); - return; - } - - final lightJSON = lightResponse.body; - final lightFile = await File("$documents/map-style-light.json") - .writeAsString(lightJSON, flush: true); - - // Update state with path - state = - state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); - - // Set to loading - state = state.copyWith(darkStyleFetched: const AsyncLoading()); - - // Fetch and save dark theme - final darkResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.dark); - - if (darkResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), - ); - _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); - return; - } - - final darkJSON = darkResponse.body; - final darkFile = await File("$documents/map-style-dark.json") - .writeAsString(darkJSON, flush: true); - - // Update state with path - state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); - } - void switchTheme(ThemeMode mode) { ref.read(appSettingsServiceProvider).setSetting( AppSettingsEnum.mapThemeMode, diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 6327f992f5..14521b06f6 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + mapLightStyleUrl: + 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), serverDiskInfo: const ServerDiskInfo( diskAvailable: "0", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index c473fbb833..255ad01247 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); } break; + case 'ServerConfigDto': + if (value is Map) { + addDefault( + value, + 'mapLightStyleUrl', + 'https://tiles.immich.cloud/v1/style/light.json', + ); + addDefault( + value, + 'mapDarkStyleUrl', + 'https://tiles.immich.cloud/v1/style/dark.json', + ); + } case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c8135519de..285514e11c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -138,7 +138,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | @@ -348,7 +347,6 @@ Class | Method | HTTP request | Description - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - - [MapTheme](doc//MapTheme.md) - [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7fa06b0487..fc0224a8c2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart'; part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; -part 'model/map_theme.dart'; part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 2846dae6c3..9644fbfc5c 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -105,62 +105,6 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e857f51e3a..828c0b9ed9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -372,8 +372,6 @@ class ApiClient { return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': return MapReverseGeocodeResponseDto.fromJson(value); - case 'MapTheme': - return MapThemeTypeTransformer().decode(value); case 'MemoriesResponse': return MemoriesResponse.fromJson(value); case 'MemoriesUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 0f3cc41097..b7c6ad5e01 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,9 +100,6 @@ String parameterToString(dynamic value) { if (value is ManualJobName) { return ManualJobNameTypeTransformer().encode(value).toString(); } - if (value is MapTheme) { - return MapThemeTypeTransformer().encode(value).toString(); - } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/map_theme.dart deleted file mode 100644 index e2553790c6..0000000000 --- a/mobile/openapi/lib/model/map_theme.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class MapTheme { - /// Instantiate a new enum with the provided [value]. - const MapTheme._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const light = MapTheme._(r'light'); - static const dark = MapTheme._(r'dark'); - - /// List of all possible values in this [enum][MapTheme]. - static const values = [ - light, - dark, - ]; - - static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MapTheme.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [MapTheme] to String, -/// and [decode] dynamic data back to [MapTheme]. -class MapThemeTypeTransformer { - factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._(); - - const MapThemeTypeTransformer._(); - - String encode(MapTheme data) => data.value; - - /// Decodes a [dynamic value][data] to a MapTheme. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - MapTheme? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'light': return MapTheme.light; - case r'dark': return MapTheme.dark; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [MapThemeTypeTransformer] instance. - static MapThemeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index c45ed32ac0..bd5c2405e2 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -17,6 +17,8 @@ class ServerConfigDto { required this.isInitialized, required this.isOnboarded, required this.loginPageMessage, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, required this.oauthButtonText, required this.trashDays, required this.userDeleteDelay, @@ -30,6 +32,10 @@ class ServerConfigDto { String loginPageMessage; + String mapDarkStyleUrl; + + String mapLightStyleUrl; + String oauthButtonText; int trashDays; @@ -42,6 +48,8 @@ class ServerConfigDto { other.isInitialized == isInitialized && other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && + other.mapDarkStyleUrl == mapDarkStyleUrl && + other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -53,12 +61,14 @@ class ServerConfigDto { (isInitialized.hashCode) + (isOnboarded.hashCode) + (loginPageMessage.hashCode) + + (mapDarkStyleUrl.hashCode) + + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -66,6 +76,8 @@ class ServerConfigDto { json[r'isInitialized'] = this.isInitialized; json[r'isOnboarded'] = this.isOnboarded; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; + json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; @@ -85,6 +97,8 @@ class ServerConfigDto { isInitialized: mapValueOfType(json, r'isInitialized')!, isOnboarded: mapValueOfType(json, r'isOnboarded')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, + mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, @@ -139,6 +153,8 @@ class ServerConfigDto { 'isInitialized', 'isOnboarded', 'loginPageMessage', + 'mapDarkStyleUrl', + 'mapLightStyleUrl', 'oauthButtonText', 'trashDays', 'userDeleteDelay', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 706ff5b8fb..4e7c711978 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3167,55 +3167,6 @@ ] } }, - "/map/style.json": { - "get": { - "operationId": "getMapStyle", - "parameters": [ - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/MapTheme" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Map" - ] - } - }, "/memories": { "get": { "operationId": "searchMemories", @@ -5356,8 +5307,8 @@ "name": "password", "required": false, "in": "query", - "example": "password", "schema": { + "example": "password", "type": "string" } }, @@ -9695,13 +9646,6 @@ ], "type": "object" }, - "MapTheme": { - "enum": [ - "light", - "dark" - ], - "type": "string" - }, "MemoriesResponse": { "properties": { "enabled": { @@ -10917,6 +10861,12 @@ "loginPageMessage": { "type": "string" }, + "mapDarkStyleUrl": { + "type": "string" + }, + "mapLightStyleUrl": { + "type": "string" + }, "oauthButtonText": { "type": "string" }, @@ -10932,6 +10882,8 @@ "isInitialized", "isOnboarded", "loginPageMessage", + "mapDarkStyleUrl", + "mapLightStyleUrl", "oauthButtonText", "trashDays", "userDeleteDelay" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8e607f7570..d1b88afabb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -928,6 +928,8 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + mapDarkStyleUrl: string; + mapLightStyleUrl: string; oauthButtonText: string; trashDays: number; userDeleteDelay: number; @@ -2138,20 +2140,6 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3469,10 +3457,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum MemoryType { OnThisDay = "on_this_day" } diff --git a/server/package-lock.json b/server/package-lock.json index ee432b9e06..9abfc6b5ce 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -733,9 +733,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -749,9 +749,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -765,9 +765,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -797,9 +797,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -813,9 +813,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -829,9 +829,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -845,9 +845,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -861,9 +861,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -877,9 +877,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -893,9 +893,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -909,9 +909,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -941,9 +941,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -957,9 +957,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -973,9 +973,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -989,9 +989,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1005,9 +1005,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1021,9 +1021,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1037,9 +1037,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -1053,9 +1053,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1069,9 +1069,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -1085,9 +1085,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -2085,16 +2085,16 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2121,6 +2121,11 @@ } } }, + "node_modules/@nestjs/core/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/event-emitter": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", @@ -2153,15 +2158,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "dependencies": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2172,6 +2177,11 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/platform-socket.io": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", @@ -2238,15 +2248,15 @@ "dev": true }, "node_modules/@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { @@ -4551,9 +4561,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -4564,9 +4574,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -4577,9 +4587,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -4590,9 +4600,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -4603,9 +4613,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -4616,9 +4626,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -4629,9 +4639,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -4642,9 +4652,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -4655,9 +4665,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -4668,9 +4678,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -4681,9 +4691,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -4694,9 +4704,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -4707,9 +4717,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -4720,9 +4730,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -4733,9 +4743,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -4746,9 +4756,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -6689,9 +6699,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -6701,7 +6711,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -7995,9 +8005,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8012,9 +8022,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8025,7 +8035,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -8097,9 +8107,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -8109,29 +8119,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -8524,36 +8534,36 @@ ] }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -8586,9 +8596,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/extend": { "version": "3.0.2", @@ -8719,12 +8729,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -10281,9 +10291,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -10308,11 +10321,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10928,9 +10941,12 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11247,9 +11263,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "node_modules/path-type": { "version": "4.0.0", @@ -11384,9 +11400,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "4.0.2", @@ -11433,9 +11449,9 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -11452,8 +11468,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11806,11 +11822,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -12862,9 +12878,9 @@ } }, "node_modules/rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -12877,22 +12893,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -13047,9 +13063,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13082,6 +13098,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -13092,14 +13116,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -13228,13 +13252,17 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13314,26 +13342,6 @@ "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -13356,9 +13364,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -13820,9 +13828,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14794,14 +14802,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -14820,6 +14828,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -14837,6 +14846,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -15167,15 +15179,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -15779,163 +15791,163 @@ } }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -16520,16 +16532,23 @@ } }, "@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/event-emitter": { @@ -16547,15 +16566,22 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "requires": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/platform-socket.io": { @@ -16605,15 +16631,15 @@ } }, "@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "requires": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" } }, @@ -18061,114 +18087,114 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "dev": true, "optional": true }, @@ -19691,9 +19717,9 @@ } }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -19703,7 +19729,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -20626,9 +20652,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -20640,9 +20666,9 @@ } }, "engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "requires": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -20653,7 +20679,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "engine.io-parser": { @@ -20704,34 +20730,34 @@ "dev": true }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "escalade": { @@ -20995,36 +21021,36 @@ "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -21051,9 +21077,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" } } }, @@ -21167,12 +21193,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -22320,9 +22346,9 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "merge-stream": { "version": "2.0.0", @@ -22341,11 +22367,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -22784,9 +22810,9 @@ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "obuf": { "version": "1.1.2", @@ -23022,9 +23048,9 @@ } }, "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "path-type": { "version": "4.0.0", @@ -23123,9 +23149,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "4.0.2", @@ -23156,13 +23182,13 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" } }, "postcss-import": { @@ -23389,11 +23415,11 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "queue-microtask": { @@ -24051,27 +24077,27 @@ } }, "rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "requires": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -24176,9 +24202,9 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -24209,6 +24235,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" } } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" } } }, @@ -24222,14 +24253,14 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-blocking": { @@ -24332,13 +24363,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "siginfo": { @@ -24403,14 +24435,6 @@ "requires": { "debug": "~4.3.4", "ws": "~8.17.1" - }, - "dependencies": { - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - } } }, "socket.io-parser": { @@ -24429,9 +24453,9 @@ "dev": true }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, "source-map-support": { "version": "0.5.21", @@ -24766,9 +24790,9 @@ "dev": true }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -25399,15 +25423,15 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "requires": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" } }, "vite-node": { @@ -25627,9 +25651,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, "xtend": { diff --git a/server/src/config.ts b/server/src/config.ts index 057c9a69e2..03ea3f111b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -285,8 +285,8 @@ export const defaults = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index d6c26c58a0..88104e6b58 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -7,7 +7,6 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,12 +21,6 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated({ sharedLink: true }) - @Get('style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } - @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 78e59e4d1a..aafadff478 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -121,6 +121,8 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + mapDarkStyleUrl!: string; + mapLightStyleUrl!: string; } export class ServerFeaturesDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 14027aa16a..336f50f39b 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -296,10 +296,12 @@ class SystemConfigMapDto { @ValidateBoolean() enabled!: boolean; - @IsString() + @IsNotEmpty() + @IsUrl() lightStyle!: string; - @IsString() + @IsNotEmpty() + @IsUrl() darkStyle!: string; } diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index ffd84a3e02..5836505e54 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -43,17 +43,6 @@ export class MapService { return this.mapRepository.getMapMarkers(userIds, albumIds, options); } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.configCore.getConfig({ withCache: false }); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.mapRepository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); - } - async reverseGeocode(dto: MapReverseGeocodeDto) { const { lat: latitude, lon: longitude } = dto; // eventually this should probably return an array of results diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index ac899f7b13..4e6a8972b0 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -186,6 +186,8 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); expect(systemMock.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e57a206765..9db90e41b3 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -129,6 +129,8 @@ export class ServerService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + mapDarkStyleUrl: config.map.darkStyle, + mapLightStyleUrl: config.map.lightStyle, }; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 7e25e0cd46..52ad6d276b 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -100,8 +100,8 @@ const updatedConfig = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 83ea3016fd..4f60131d69 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -6,8 +6,8 @@ import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils'; - import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk'; + import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; @@ -57,11 +57,13 @@ let map: maplibregl.Map; let marker: maplibregl.Marker | null = null; - $: style = (() => - getMapStyle({ - theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme, - key: getKey(), - }) as Promise)(); + $: style = (async () => { + const config = await getServerConfig(); + const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT; + const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl; + const style = await fetch(styleUrl).then((response) => response.json()); + return style as StyleSpecification; + })(); function handleAssetClick(assetId: string, map: Map | null) { if (!map) { diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 14d1e4e66e..358765fe0b 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -32,6 +32,8 @@ export const serverConfig = writable({ isInitialized: false, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: '', + mapLightStyleUrl: '', }); export const retrieveServerConfig = async () => { From ec32a9e6109342bcb9871eac0fa22bb055cfb3af Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 04:03:59 +0200 Subject: [PATCH 047/599] fix: set min values for face detection to reasonable values (#12877) fix: set min values for face detection to >0 --- mobile/openapi/lib/model/facial_recognition_config.dart | 4 ++-- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/model-config.dto.ts | 4 ++-- .../machine-learning-settings.svelte | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 4acfd4e20f..439efbbfae 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -22,14 +22,14 @@ class FacialRecognitionConfig { bool enabled; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 2 double maxDistance; /// Minimum value: 1 int minFaces; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 1 double minScore; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4e7c711978..99ea313063 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9119,7 +9119,7 @@ "maxDistance": { "format": "double", "maximum": 2, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "minFaces": { @@ -9129,7 +9129,7 @@ "minScore": { "format": "double", "maximum": 1, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "modelName": { diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index dffacc793d..f8b9e2043f 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -27,14 +27,14 @@ export class DuplicateDetectionConfig extends TaskConfig { export class FacialRecognitionConfig extends ModelConfig { @IsNumber() - @Min(0) + @Min(0.1) @Max(1) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) minScore!: number; @IsNumber() - @Min(0) + @Min(0.1) @Max(2) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 05a5224bd0..aac8cd5212 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -145,7 +145,7 @@ desc={$t('admin.machine_learning_min_detection_score_description')} bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" - min={0} + min={0.1} max={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.minScore !== @@ -158,7 +158,7 @@ desc={$t('admin.machine_learning_max_recognition_distance_description')} bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" - min={0} + min={0.1} max={2} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.maxDistance !== From 56f680ce04506f7969104a8866eaca330602af3a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:05:04 -0400 Subject: [PATCH 048/599] chore(deps): update typescript-projects (#12882) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 200 ++++++------- docs/package-lock.json | 6 +- e2e/package-lock.json | 424 +++++++++++++-------------- server/package-lock.json | 607 ++++++++++++++++++++------------------- web/package-lock.json | 258 ++++++++--------- 5 files changed, 718 insertions(+), 777 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f74e86a385..6e148fbe09 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1353,17 +1353,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1387,16 +1387,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1416,14 +1416,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1434,14 +1434,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1459,9 +1459,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1473,14 +1473,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1502,16 +1502,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1525,13 +1525,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1543,9 +1543,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1566,8 +1566,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1576,14 +1576,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1592,9 +1592,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1606,7 +1606,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -1633,13 +1633,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -1647,13 +1647,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -1661,23 +1661,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1688,13 +1675,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1702,19 +1689,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -4241,9 +4215,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4283,19 +4257,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -4306,7 +4280,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4321,8 +4295,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/docs/package-lock.json b/docs/package-lock.json index 5f14d39ac7..3b4e6c4f95 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -16091,9 +16091,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ab4fd53fbf..73c6ac6175 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1149,13 +1149,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz", - "integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.1.tgz", + "integrity": "sha512-dbWpcNQZ5nj16m+A5UNScYx7HX5trIy7g4phrcitn+Nk83S32EBX/CLU4hiF4RGKX/yRc93AAqtfaXB7JWBd4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.47.0" + "playwright": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -1165,9 +1165,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", - "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -1179,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", - "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", - "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -1207,9 +1207,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", - "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", - "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -1235,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", - "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -1249,9 +1249,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", - "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -1263,9 +1263,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", - "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -1277,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", - "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -1291,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", - "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -1305,9 +1305,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", - "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -1319,9 +1319,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", - "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -1333,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", - "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -1347,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", - "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1361,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", - "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1375,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", - "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1471,9 +1471,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, "license": "MIT" }, @@ -1596,9 +1596,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.9.tgz", - "integrity": "sha512-M4mYeJZRBD9lCBCGa72F44uKSV9eJrAFfjlPJagdA6pgIr2OPJULFB7nqnZzOdqXG0qzHlgtZKzTdIgbmHitSg==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, "license": "MIT", "dependencies": { @@ -1733,17 +1733,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1767,16 +1767,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1796,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1814,14 +1814,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1839,9 +1839,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1853,14 +1853,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1908,16 +1908,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1931,13 +1931,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1949,9 +1949,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1972,8 +1972,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1982,14 +1982,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1998,9 +1998,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,7 +2012,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2039,13 +2039,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2053,13 +2053,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2067,23 +2067,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2094,13 +2081,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2108,19 +2095,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4168,9 +4142,9 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.2.tgz", + "integrity": "sha512-ILI2xx/I57b20sd7rHZvgiiQrmp2mcotwsAH+5ajbpFQbrYVQdNHYlQhoA5cFb78CgtBOxtC05TeA+mcgkuCqQ==", "dev": true, "license": "MIT", "funding": { @@ -5039,15 +5013,15 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5074,10 +5048,11 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", - "dev": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -5098,19 +5073,21 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "dev": true, + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", - "dev": true + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dev": true, + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", @@ -5158,13 +5135,13 @@ } }, "node_modules/playwright": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz", - "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.1.tgz", + "integrity": "sha512-SUEKi6947IqYbKxRiqnbUobVZY4bF1uu+ZnZNJX9DfU1tlf2UhWfvVjLf01pQx9URsOr18bFVUKXmanYWhbfkw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.47.0" + "playwright-core": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -5177,9 +5154,9 @@ } }, "node_modules/playwright-core": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz", - "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.1.tgz", + "integrity": "sha512-i1iyJdLftqtt51mEk6AhYFaAJCDx0xQ/O5NU8EKaWFgMjItPVma542Nh/Aq8aLCjIJSzjaiEQGW/nyqLkGF1OQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5629,9 +5606,9 @@ } }, "node_modules/rollup": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", - "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "license": "MIT", "dependencies": { @@ -5645,25 +5622,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.3", - "@rollup/rollup-android-arm64": "4.21.3", - "@rollup/rollup-darwin-arm64": "4.21.3", - "@rollup/rollup-darwin-x64": "4.21.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", - "@rollup/rollup-linux-arm-musleabihf": "4.21.3", - "@rollup/rollup-linux-arm64-gnu": "4.21.3", - "@rollup/rollup-linux-arm64-musl": "4.21.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", - "@rollup/rollup-linux-riscv64-gnu": "4.21.3", - "@rollup/rollup-linux-s390x-gnu": "4.21.3", - "@rollup/rollup-linux-x64-gnu": "4.21.3", - "@rollup/rollup-linux-x64-musl": "4.21.3", - "@rollup/rollup-win32-arm64-msvc": "4.21.3", - "@rollup/rollup-win32-ia32-msvc": "4.21.3", - "@rollup/rollup-win32-x64-msvc": "4.21.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6403,9 +6387,9 @@ } }, "node_modules/vite": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", - "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6463,9 +6447,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -6500,19 +6484,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -6523,7 +6507,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6538,8 +6522,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/server/package-lock.json b/server/package-lock.json index 9abfc6b5ce..ba9f33dc1e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2043,12 +2043,12 @@ } }, "node_modules/@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2070,6 +2070,11 @@ } } }, + "node_modules/@nestjs/common/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/config": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", @@ -2183,12 +2188,12 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "dependencies": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2200,10 +2205,15 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "dependencies": { "cron": "3.1.7", "uuid": "10.0.0" @@ -2280,12 +2290,12 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2306,6 +2316,12 @@ } } }, + "node_modules/@nestjs/testing/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2322,13 +2338,13 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", @@ -2343,6 +2359,11 @@ } } }, + "node_modules/@nestjs/websockets/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@next/env": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", @@ -5376,9 +5397,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -5485,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -5604,16 +5625,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5637,15 +5658,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -5665,13 +5686,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5682,13 +5703,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5706,9 +5727,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5719,13 +5740,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5771,15 +5792,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5793,12 +5814,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5810,9 +5831,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -5832,8 +5853,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5851,13 +5872,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -5866,9 +5887,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "dependencies": { "@vitest/spy": "^2.1.0-beta.1", @@ -5879,7 +5900,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -5914,12 +5935,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -5927,12 +5948,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -5940,18 +5961,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/snapshot/node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -5962,9 +5971,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "dependencies": { "tinyspy": "^3.0.0" @@ -5974,12 +5983,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -5987,18 +5996,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -11311,13 +11308,13 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -11343,9 +11340,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -11364,17 +11361,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -14565,9 +14562,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -14582,6 +14579,9 @@ "url": "https://github.com/sponsors/faisalman" } ], + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -14861,9 +14861,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -14901,18 +14901,18 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -14923,7 +14923,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14938,8 +14938,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, @@ -16512,13 +16512,20 @@ } }, "@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "requires": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/config": { @@ -16585,18 +16592,25 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "requires": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "requires": { "cron": "3.1.7", "uuid": "10.0.0" @@ -16644,12 +16658,20 @@ } }, "@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "requires": { - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + } } }, "@nestjs/typeorm": { @@ -16661,13 +16683,20 @@ } }, "@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@next/env": { @@ -18680,9 +18709,9 @@ } }, "@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "requires": { "@types/node": "*" @@ -18776,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "requires": { "@types/prop-types": "*", @@ -18895,16 +18924,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -18912,54 +18941,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" } }, "@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -18989,31 +19018,31 @@ } }, "@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" } }, "@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "requires": { "@ampproject/remapping": "^2.3.0", @@ -19042,21 +19071,21 @@ } }, "@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "requires": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "requires": { "@vitest/spy": "^2.1.0-beta.1", @@ -19085,35 +19114,26 @@ } }, "@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "requires": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - }, "magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -19126,34 +19146,23 @@ } }, "@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "requires": { "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" - }, - "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - } } }, "@webassemblyjs/ast": { @@ -23084,14 +23093,14 @@ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" }, "pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "requires": { "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" } @@ -23103,9 +23112,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "pg-int8": { "version": "1.0.1", @@ -23118,15 +23127,15 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "requires": {} }, "pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "pg-types": { "version": "2.2.0", @@ -25268,9 +25277,9 @@ "devOptional": true }, "ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==" }, "uglify-js": { "version": "3.17.4", @@ -25435,9 +25444,9 @@ } }, "vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "requires": { "cac": "^6.7.14", @@ -25458,18 +25467,18 @@ } }, "vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "requires": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -25480,7 +25489,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "dependencies": { diff --git a/web/package-lock.json b/web/package-lock.json index ce30d1ccb4..b652f58ce0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -759,9 +759,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz", - "integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.1.tgz", + "integrity": "sha512-4mDeYIgM3By7X6t5E6eYwLAa+2h4DeZDF7thhzIg6XB76jeEvMwadYAMCFJL/R4AnEBcAUO9+gL0vhy3s+qvZA==", "dev": true, "funding": [ { @@ -1875,9 +1875,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", - "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", + "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1885,9 +1885,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.4.tgz", - "integrity": "sha512-eX+ob5uWr0bTLMKeG9nhhM84aR88hqiLiyEfWZPX7ijhk/wlmYSUX9nOiaVHh2ct1U+Ju9Hhb90Copw+ZNOB8w==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.8.tgz", + "integrity": "sha512-n66u46ZeqHltiTm0BEjWptYmCrCY0EltEEvakmC7d5o5ZejDbOvOWm914mebbRKaP2Bezv65TNCod/wqvw/0KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1901,9 +1901,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.26", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.26.tgz", - "integrity": "sha512-8l1JTIM2L+bS8ebq1E+nGjv/YSKSnD9Q19bYIUkc41vaEG2JjVUx6ikvPIJv2hkQAuqJLzoPrXlKk4KcyWOv3Q==", + "version": "2.5.28", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.28.tgz", + "integrity": "sha512-/O7pvFGBsQPcFa9UrW8eUC5uHTOXLsUp3SN0dY6YmRAL9nfPSrJsSJk//j5vMpinSshzUjteAFcfQTU+04Ka1w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2318,17 +2318,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2352,16 +2352,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -2381,14 +2381,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2399,14 +2399,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2424,9 +2424,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -2438,14 +2438,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2493,16 +2493,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2516,13 +2516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2534,9 +2534,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2557,8 +2557,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2567,14 +2567,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2583,9 +2583,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2597,7 +2597,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2624,13 +2624,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2638,13 +2638,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2652,23 +2652,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2679,13 +2666,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2693,23 +2680,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@zoom-image/core": { - "version": "0.37.1", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.37.1.tgz", - "integrity": "sha512-mIJaZJBi3jvOD2gtzoSe4yhnxfvx7GcYlVTLoJE6VPawb3Ei5dvHuRRXa8/dNHtCf1Xf2RNSEm1Za2+TqkAiBQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.38.0.tgz", + "integrity": "sha512-rA6/qTGfsRtWRs+WfMF0dIs+Ft9GBFusxXzEqqFsQa/0iYtN0MmOiuKzXGYPcIFKTbmQW/qqk0afIBtWd9163g==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2720,12 +2694,12 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.21.tgz", - "integrity": "sha512-242xKpIaVZC/cymvNF4+JlcKwAaM9l3W2QS4DHSsnqT8xvPBgBgns+1lqOuYYKSAa85DB1UL0NMBhTg8Gk4RpA==", + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.22.tgz", + "integrity": "sha512-lExo4M511/HtkmCsBzV5f8ABs8bEMZGtIrwl1pJro77iJ+5j9Yt7KUlPs6o+Yp028T6fqGJUsOCxCNWNZn9BIg==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.37.1" + "@zoom-image/core": "0.38.0" }, "funding": { "type": "github", @@ -3864,9 +3838,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.44.0.tgz", + "integrity": "sha512-wav4MOs02vBb1WjvTCYItwJCxMkuk2Z4p+K/eyjL0N/z7ahXLP+0LtQQjiKc2ezuif7GnZLbD1F3o1VHzSvdVg==", "dev": true, "license": "MIT", "dependencies": { @@ -3880,7 +3854,7 @@ "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.1.0", "semver": "^7.6.2", - "svelte-eslint-parser": "^0.41.0" + "svelte-eslint-parser": "^0.41.1" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -7160,9 +7134,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz", + "integrity": "sha512-08ndI6zTghzI8SuJAFpvMbA/haPSGn3xz19pjre19yYMw8Nw/wQJ2PrZBI/L8ijGTgtkWCQQiLLy+Z1tfaCwNA==", "dev": true, "license": "MIT", "dependencies": { @@ -7678,9 +7652,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "dev": true, "license": "MIT", "dependencies": { @@ -8246,9 +8220,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -8282,19 +8256,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -8305,7 +8279,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8320,8 +8294,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, From e0fa3cdbc75817226bebe6eb58dd9a069e112d39 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:24:48 +0200 Subject: [PATCH 049/599] refactor(mobile): more repositories (#12879) * ExifInfoRepository * ActivityApiRepository * initial AssetApiRepository --- mobile/analysis_options.yaml | 9 +-- .../interfaces/activity_api.interface.dart | 16 ++++ mobile/lib/interfaces/asset.interface.dart | 12 +++ .../lib/interfaces/asset_api.interface.dart | 16 ++++ .../lib/interfaces/exif_info.interface.dart | 9 +++ .../lib/models/activities/activity.model.dart | 17 ++-- .../providers/activity_service.provider.dart | 4 +- .../activity_statistics.provider.dart | 2 +- .../repositories/activity_api.repository.dart | 67 +++++++++++++++ .../repositories/album_api.repository.dart | 25 +++--- mobile/lib/repositories/asset.repository.dart | 80 ++++++++++++++++++ .../repositories/asset_api.repository.dart | 25 ++++++ .../lib/repositories/base_api.repository.dart | 11 +++ .../repositories/exif_info.repository.dart | 28 +++++++ mobile/lib/services/activity.service.dart | 48 ++++------- mobile/lib/services/asset.service.dart | 52 ++++++++++++ .../services/asset_description.service.dart | 66 --------------- .../services/backup_verification.service.dart | 81 +++++++------------ .../asset_viewer/description_input.dart | 10 ++- .../activity_statistics_provider_test.dart | 7 +- 20 files changed, 392 insertions(+), 193 deletions(-) create mode 100644 mobile/lib/interfaces/activity_api.interface.dart create mode 100644 mobile/lib/interfaces/asset_api.interface.dart create mode 100644 mobile/lib/interfaces/exif_info.interface.dart create mode 100644 mobile/lib/repositories/activity_api.repository.dart create mode 100644 mobile/lib/repositories/asset_api.repository.dart create mode 100644 mobile/lib/repositories/base_api.repository.dart create mode 100644 mobile/lib/repositories/exif_info.repository.dart delete mode 100644 mobile/lib/services/asset_description.service.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 8f9d41d736..e996a54372 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,7 +64,7 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,user}.repository.dart + - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart # acceptable exceptions for the time being - integration_test/test_utils/general_helper.dart - lib/main.dart @@ -75,7 +75,7 @@ custom_lint: - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart - import_rule_openapi: @@ -83,13 +83,12 @@ custom_lint: restrict: package:openapi allowed: # requried / wanted - - lib/repositories/album_api.repository.dart + - lib/repositories/*_api.repository.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - - lib/models/activities/activity.model.dart - lib/models/map/map_marker.model.dart - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart @@ -102,7 +101,7 @@ custom_lint: - lib/providers/search/{people,search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart new file mode 100644 index 0000000000..99aef6f4d4 --- /dev/null +++ b/mobile/lib/interfaces/activity_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/models/activities/activity.model.dart'; + +abstract interface class IActivityApiRepository { + Future> getAll( + String albumId, { + String? assetId, + }); + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }); + Future delete(String id); + Future getStats(String albumId, {String? assetId}); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 2574e52112..98f4c7687c 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -7,4 +7,16 @@ abstract interface class IAssetRepository { Future> getAllByRemoteId(Iterable ids); Future> getByAlbum(Album album, {User? notOwnedBy}); Future deleteById(List ids); + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }); + + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart new file mode 100644 index 0000000000..201c85cea7 --- /dev/null +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetApiRepository { + // Future get(String id); + + // Future> getAll(); + + // Future create(Asset asset); + + Future update( + String id, { + String? description, + }); + + // Future delete(String id); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart new file mode 100644 index 0000000000..fa8ca08f9d --- /dev/null +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/entities/exif_info.entity.dart'; + +abstract interface class IExifInfoRepository { + Future get(int id); + + Future update(ExifInfo exifInfo); + + Future delete(int id); +} diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index 6adb80dca9..4702753f41 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:openapi/api.dart'; enum ActivityType { comment, like } @@ -38,16 +37,6 @@ class Activity { ); } - Activity.fromDto(ActivityResponseDto dto) - : id = dto.id, - assetId = dto.assetId, - comment = dto.comment, - createdAt = dto.createdAt, - type = dto.type == ReactionType.comment - ? ActivityType.comment - : ActivityType.like, - user = User.fromSimpleUserDto(dto.user); - @override String toString() { return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; @@ -75,3 +64,9 @@ class Activity { user.hashCode; } } + +class ActivityStats { + final int comments; + + const ActivityStats({required this.comments}); +} diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index dcfaac883f..6bd139c565 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,9 +1,9 @@ +import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod ActivityService activityService(ActivityServiceRef ref) => - ActivityService(ref.watch(apiServiceProvider)); + ActivityService(ref.watch(activityApiRepositoryProvider)); diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index afb43e8cba..b1d2b4b987 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics { ref .watch(activityServiceProvider) .getStatistics(albumId, assetId: assetId) - .then((comments) => state = comments); + .then((stats) => state = stats.comments); return 0; } diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart new file mode 100644 index 0000000000..0b1b4d99f3 --- /dev/null +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -0,0 +1,67 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final activityApiRepositoryProvider = Provider( + (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), +); + +class ActivityApiRepository extends BaseApiRepository + implements IActivityApiRepository { + final ActivitiesApi _api; + + ActivityApiRepository(this._api); + + @override + Future> getAll(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivities(albumId, assetId: assetId)); + return response.map(_toActivity).toList(); + } + + @override + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + final dto = ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ); + final response = await checkNull(_api.createActivity(dto)); + return _toActivity(response); + } + + @override + Future delete(String id) { + return checkNull(_api.deleteActivity(id)); + } + + @override + Future getStats(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivityStatistics(albumId, assetId: assetId)); + return ActivityStats(comments: response.comments); + } + + static Activity _toActivity(ActivityResponseDto dto) => Activity( + id: dto.id, + createdAt: dto.createdAt, + type: dto.type == ReactionType.comment + ? ActivityType.comment + : ActivityType.like, + user: User.fromSimpleUserDto(dto.user), + assetId: dto.assetId, + comment: dto.comment, + ); +} diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 6b7865f8e4..0e27e44684 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -1,30 +1,31 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/errors.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository implements IAlbumApiRepository { +class AlbumApiRepository extends BaseApiRepository + implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @override Future get(String id) async { - final dto = await _checkNull(_api.getAlbumInfo(id)); + final dto = await checkNull(_api.getAlbumInfo(id)); return _toAlbum(dto); } @override Future> getAll({bool? shared}) async { - final dtos = await _checkNull(_api.getAllAlbums(shared: shared)); + final dtos = await checkNull(_api.getAllAlbums(shared: shared)); return dtos.map(_toAlbum).toList().cast(); } @@ -37,7 +38,7 @@ class AlbumApiRepository implements IAlbumApiRepository { final users = sharedUserIds.map( (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), ); - final responseDto = await _checkNull( + final responseDto = await checkNull( _api.createAlbum( CreateAlbumDto( albumName: name, @@ -57,7 +58,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String? description, bool? activityEnabled, }) async { - final response = await _checkNull( + final response = await checkNull( _api.updateAlbumInfo( albumId, UpdateAlbumDto( @@ -81,7 +82,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.addAssetsToAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -106,7 +107,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.removeAssetFromAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -127,7 +128,7 @@ class AlbumApiRepository implements IAlbumApiRepository { Future addUsers(String albumId, Iterable userIds) async { final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); - final response = await _checkNull( + final response = await checkNull( _api.addUsersToAlbum( albumId, AddUsersDto(albumUsers: albumUsers), @@ -141,12 +142,6 @@ class AlbumApiRepository implements IAlbumApiRepository { return _api.removeUserFromAlbum(albumId, userId); } - static Future _checkNull(Future future) async { - final response = await future; - if (response == null) throw NoResponseDtoError(); - return response; - } - static Album _toAlbum(AlbumResponseDto dto) { final Album album = Album( remoteId: dto.id, diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 8ec028f728..c6012af371 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -35,4 +35,84 @@ class AssetRepository implements IAssetRepository { @override Future> getAllByRemoteId(Iterable ids) => _db.assets.getAllByRemoteId(ids); + + @override + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }) { + if (remote == null) { + return _db.assets + .where() + .ownerIdEqualToAnyChecksum(ownerId) + .limit(limit) + .findAll(); + } + final QueryBuilder query; + if (remote) { + query = _db.assets + .where() + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + } else { + query = _db.assets + .where() + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + } + + return query.limit(limit).findAll(); + } + + @override + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }) { + final QueryBuilder query; + if (remote == null) { + query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); + } else if (remote) { + query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); + } else { + query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + } + return _getMatchesImpl(query, ownerId, assets, limit); + } } + +Future> _getMatchesImpl( + QueryBuilder query, + int ownerId, + List assets, + int limit, +) => + query + .ownerIdEqualTo(ownerId) + .anyOf( + assets, + (q, Asset a) => q + .fileNameEqualTo(a.fileName) + .and() + .durationInSecondsEqualTo(a.durationInSeconds) + .and() + .fileCreatedAtBetween( + a.fileCreatedAt.subtract(const Duration(hours: 12)), + a.fileCreatedAt.add(const Duration(hours: 12)), + ) + .and() + .not() + .checksumEqualTo(a.checksum), + ) + .sortByFileName() + .thenByFileCreatedAt() + .thenByFileModifiedAt() + .limit(limit) + .findAll(); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart new file mode 100644 index 0000000000..3ad0e1cba0 --- /dev/null +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -0,0 +1,25 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), +); + +class AssetApiRepository extends BaseApiRepository + implements IAssetApiRepository { + final AssetsApi _api; + + AssetApiRepository(this._api); + + @override + Future update(String id, {String? description}) async { + final response = await checkNull( + _api.updateAsset(id, UpdateAssetDto(description: description)), + ); + return Asset.remote(response); + } +} diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/base_api.repository.dart new file mode 100644 index 0000000000..418cba84f8 --- /dev/null +++ b/mobile/lib/repositories/base_api.repository.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/errors.dart'; + +abstract class BaseApiRepository { + @protected + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart new file mode 100644 index 0000000000..a165e98bdb --- /dev/null +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final exifInfoRepositoryProvider = + Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); + +class ExifInfoRepository implements IExifInfoRepository { + final Isar _db; + + ExifInfoRepository( + this._db, + ); + + @override + Future delete(int id) => _db.exifInfos.delete(id); + + @override + Future get(int id) => _db.exifInfos.get(id); + + @override + Future update(ExifInfo exifInfo) async { + await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + return exifInfo; + } +} diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 58af26e204..5496041416 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,41 +1,31 @@ -import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; class ActivityService with ErrorLoggerMixin { - final ApiService _apiService; + final IActivityApiRepository _activityApiRepository; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._apiService); + ActivityService(this._activityApiRepository); Future> getAllActivities( String albumId, { String? assetId, }) async { return logError( - () async { - final list = await _apiService.activitiesApi - .getActivities(albumId, assetId: assetId); - return list != null ? list.map(Activity.fromDto).toList() : []; - }, + () => _activityApiRepository.getAll(albumId, assetId: assetId), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); } - Future getStatistics(String albumId, {String? assetId}) async { + Future getStatistics(String albumId, {String? assetId}) async { return logError( - () async { - final dto = await _apiService.activitiesApi - .getActivityStatistics(albumId, assetId: assetId); - return dto?.comments ?? 0; - }, - defaultValue: 0, + () => _activityApiRepository.getStats(albumId, assetId: assetId), + defaultValue: const ActivityStats(comments: 0), errorMessage: "Failed to statistics for album $albumId", ); } @@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin { Future removeActivity(String id) async { return logError( () async { - await _apiService.activitiesApi.deleteActivity(id); + await _activityApiRepository.delete(id); return true; }, defaultValue: false, @@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin { String? comment, }) async { return guardError( - () async { - final dto = await _apiService.activitiesApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }, + () => _activityApiRepository.create( + albumId, + type, + assetId: assetId, + comment: comment, + ), errorMessage: "Failed to create $type for album $albumId", ); } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 90c46ae90a..262040026e 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -9,9 +9,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -24,6 +28,8 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( + ref.watch(assetApiRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), @@ -34,6 +40,8 @@ final assetServiceProvider = Provider( ); class AssetService { + final IAssetApiRepository _assetApiRepository; + final IExifInfoRepository _exifInfoRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; @@ -43,6 +51,8 @@ class AssetService { final Isar _db; AssetService( + this._assetApiRepository, + this._exifInfoRepository, this._apiService, this._syncService, this._userService, @@ -342,4 +352,46 @@ class AssetService { log.severe("Error while syncing uploaded asset to albums", error, stack); } } + + Future setDescription( + Asset asset, + String newDescription, + ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + final result = await _assetApiRepository.update( + remoteAssetId, + description: newDescription, + ); + + final description = result.exifInfo?.description; + + if (description != null) { + var exifInfo = await _exifInfoRepository.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = description; + await _exifInfoRepository.update(exifInfo); + } + } + } + + Future getDescription(Asset asset) async { + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (localExifId == null) { + return ""; + } + + final exifInfo = await _exifInfoRepository.get(localExifId); + + return exifInfo?.description ?? ""; + } } diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart deleted file mode 100644 index 196e29dc6a..0000000000 --- a/mobile/lib/services/asset_description.service.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; -import 'package:openapi/api.dart'; - -class AssetDescriptionService { - AssetDescriptionService(this._db, this._api); - - final Isar _db; - final ApiService _api; - - Future setDescription( - Asset asset, - String newDescription, - ) async { - final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - final result = await _api.assetsApi.updateAsset( - remoteAssetId, - UpdateAssetDto(description: newDescription), - ); - - final description = result?.exifInfo?.description; - - if (description != null) { - var exifInfo = await _db.exifInfos.get(localExifId); - - if (exifInfo != null) { - exifInfo.description = description; - await _db.writeTxn( - () => _db.exifInfos.put(exifInfo), - ); - } - } - } - - String getAssetDescription(Asset asset) { - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (localExifId == null) { - return ""; - } - - final exifInfo = _db.exifInfos.getSync(localExifId); - - return exifInfo?.description ?? ""; - } -} - -final assetDescriptionServiceProvider = Provider( - (ref) => AssetDescriptionService( - ref.watch(dbProvider), - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 66a61d2914..da9d8da164 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -8,41 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; /// Finds duplicates originating from missing EXIF information class BackupVerificationService { - final Isar _db; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; - BackupVerificationService(this._db, this._fileMediaRepository); + BackupVerificationService( + this._fileMediaRepository, + this._assetRepository, + this._exifInfoRepository, + ); /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { final owner = Store.get(StoreKey.currentUser).isarId; - final List onlyLocal = await _db.assets - .where() - .remoteIdIsNull() - .filter() - .ownerIdEqualTo(owner) - .localIdIsNotNull() - .findAll(); - final List remoteMatches = await _getMatches( - _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(), - owner, - onlyLocal, - limit, + final List onlyLocal = await _assetRepository.getAll( + ownerId: owner, + remote: false, + limit: limit, ); - final List localMatches = await _getMatches( - _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), - owner, - remoteMatches, - limit, + final List remoteMatches = await _assetRepository.getMatches( + assets: onlyLocal, + ownerId: owner, + remote: true, + limit: limit, + ); + final List localMatches = await _assetRepository.getMatches( + assets: remoteMatches, + ownerId: owner, + remote: false, + limit: limit, ); final List deleteCandidates = [], originals = []; @@ -52,7 +57,7 @@ class BackupVerificationService { localMatches, compare: (a, b) => a.fileName.compareTo(b.fileName), both: (a, b) async { - a.exifInfo = await _db.exifInfos.get(a.id); + a.exifInfo = await _exifInfoRepository.get(a.id); deleteCandidates.add(a); originals.add(b); return false; @@ -192,35 +197,6 @@ class BackupVerificationService { return bytes.buffer.asUint64List(start); } - static Future> _getMatches( - QueryBuilder query, - int ownerId, - List assets, - int limit, - ) => - query - .ownerIdEqualTo(ownerId) - .anyOf( - assets, - (q, Asset a) => q - .fileNameEqualTo(a.fileName) - .and() - .durationInSecondsEqualTo(a.durationInSeconds) - .and() - .fileCreatedAtBetween( - a.fileCreatedAt.subtract(const Duration(hours: 12)), - a.fileCreatedAt.add(const Duration(hours: 12)), - ) - .and() - .not() - .checksumEqualTo(a.checksum), - ) - .sortByFileName() - .thenByFileCreatedAt() - .thenByFileModifiedAt() - .limit(limit) - .findAll(); - static bool _sameExceptTimeZone(DateTime a, DateTime b) { final ms = a.isAfter(b) ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch @@ -233,7 +209,8 @@ class BackupVerificationService { final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( - ref.watch(dbProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ), ); diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 18ef394e2d..3fdd40130a 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/asset_description.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionServiceProvider); + final assetService = ref.watch(assetServiceProvider); final owner = ref.watch(currentUserProvider); final hasError = useState(false); final assetWithExif = ref.watch(assetDetailProvider(asset)); useEffect( () { - controller.text = descriptionProvider.getAssetDescription(asset); + assetService + .getDescription(asset) + .then((value) => controller.text = value); return null; }, [assetWithExif.value], @@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget { submitDescription(String description) async { hasError.value = false; try { - await descriptionProvider.setDescription( + await assetService.setDescription( asset, description, ); diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart index 9edabcc0d0..0216528ddd 100644 --- a/mobile/test/modules/activity/activity_statistics_provider_test.dart +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:mocktail/mocktail.dart'; @@ -25,7 +26,7 @@ void main() { test('Returns the proper count family', () async { when( () => activityMock.getStatistics('test-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 5); + ).thenAnswer((_) async => const ActivityStats(comments: 5)); // Read here to make the getStatistics call container.read(activityStatisticsProvider('test-album', 'test-asset')); @@ -50,7 +51,7 @@ void main() { test('Adds activity', () async { when( () => activityMock.getStatistics('test-album'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('test-album'); container.listen( @@ -71,7 +72,7 @@ void main() { test('Removes activity', () async { when( () => activityMock.getStatistics('new-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('new-album', 'test-asset'); container.listen( From 202082f62ee6ad4f9a4a8fb19e2c3b486ec7bf9e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:50:21 +0200 Subject: [PATCH 050/599] refactor(mobile): use repositories in a number of services (#12891) * UserService * PartnerService * HashService * MemoryService * PersonService * SearchService * StackService --- mobile/analysis_options.yaml | 14 ++-- mobile/lib/constants/constants.dart | 1 + mobile/lib/interfaces/asset.interface.dart | 5 ++ .../lib/interfaces/asset_api.interface.dart | 2 + .../lib/interfaces/partner_api.interface.dart | 13 +++ .../lib/interfaces/person_api.interface.dart | 22 +++++ mobile/lib/interfaces/user.interface.dart | 2 + mobile/lib/interfaces/user_api.interface.dart | 11 +++ .../models/search/search_filter.model.dart | 6 +- .../lib/pages/common/gallery_viewer.page.dart | 4 +- .../lib/pages/search/search_input.page.dart | 4 +- .../activity_service.provider.g.dart | 2 +- .../activity_statistics.provider.g.dart | 2 +- .../suggested_shared_users.provider.dart | 2 +- .../providers/map/map_state.provider.g.dart | 2 +- .../lib/providers/search/people.provider.dart | 4 +- .../providers/search/people.provider.g.dart | 7 +- mobile/lib/repositories/asset.repository.dart | 25 ++++++ .../repositories/asset_api.repository.dart | 31 ++++++- .../repositories/partner_api.repository.dart | 51 ++++++++++++ .../repositories/person_api.repository.dart | 38 +++++++++ mobile/lib/repositories/user.repository.dart | 16 ++++ .../lib/repositories/user_api.repository.dart | 41 ++++++++++ mobile/lib/services/background.service.dart | 19 +++-- mobile/lib/services/hash.service.dart | 29 +++---- mobile/lib/services/memory.service.dart | 13 ++- mobile/lib/services/partner.service.dart | 62 ++++++-------- mobile/lib/services/person.service.dart | 77 +++++++----------- mobile/lib/services/person.service.g.dart | 2 +- mobile/lib/services/search.service.dart | 12 +-- mobile/lib/services/stack.service.dart | 15 ++-- mobile/lib/services/user.service.dart | 80 ++++++++----------- mobile/lib/utils/image_url_builder.dart | 4 +- .../widgets/asset_grid/thumbnail_image.dart | 4 +- .../search/search_filter/people_picker.dart | 8 +- 35 files changed, 416 insertions(+), 214 deletions(-) create mode 100644 mobile/lib/constants/constants.dart create mode 100644 mobile/lib/interfaces/partner_api.interface.dart create mode 100644 mobile/lib/interfaces/person_api.interface.dart create mode 100644 mobile/lib/interfaces/user_api.interface.dart create mode 100644 mobile/lib/repositories/partner_api.repository.dart create mode 100644 mobile/lib/repositories/person_api.repository.dart create mode 100644 mobile/lib/repositories/user_api.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index e996a54372..6a7d7a6b4d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -69,14 +69,14 @@ custom_lint: - integration_test/test_utils/general_helper.dart - lib/main.dart - lib/routing/router.dart - - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - lib/utils/{db,migration,renderlist_generator}.dart - test/**.dart # refactor to make the providers and services testable - - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/pages/common/album_asset_selection.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/widgets/asset_grid/asset_grid_data_structure.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories @@ -90,18 +90,16 @@ custom_lint: - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - lib/models/map/map_marker.model.dart - - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - - lib/pages/search/search_input.page.dart - lib/providers/asset_viewer/asset_people.provider.dart - lib/providers/authentication.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - - lib/providers/search/{people,search,search_filter}.provider.dart + - lib/providers/search/{search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,search,shared_link,stack,trash}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart new file mode 100644 index 0000000000..8b74b1a66f --- /dev/null +++ b/mobile/lib/constants/constants.dart @@ -0,0 +1 @@ +const int noDbId = -9223372036854775808; // from Isar diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 98f4c7687c..0d2dcfa1b5 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IAssetRepository { @@ -12,6 +13,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + Future> updateAll(List assets); Future> getMatches({ required List assets, @@ -19,4 +21,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + + Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart index 201c85cea7..fe3320c9bb 100644 --- a/mobile/lib/interfaces/asset_api.interface.dart +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -13,4 +13,6 @@ abstract interface class IAssetApiRepository { }); // Future delete(String id); + + Future> search({List personIds = const []}); } diff --git a/mobile/lib/interfaces/partner_api.interface.dart b/mobile/lib/interfaces/partner_api.interface.dart new file mode 100644 index 0000000000..bca1baf66d --- /dev/null +++ b/mobile/lib/interfaces/partner_api.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IPartnerApiRepository { + Future> getAll(Direction direction); + Future create(String id); + Future update(String id, {required bool inTimeline}); + Future delete(String id); +} + +enum Direction { + sharedWithMe, + sharedByMe, +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart new file mode 100644 index 0000000000..b2fa28df8c --- /dev/null +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -0,0 +1,22 @@ +abstract interface class IPersonApiRepository { + Future> getAll(); + Future update(String id, {String? name}); +} + +class Person { + Person({ + required this.id, + required this.isHidden, + required this.name, + required this.thumbnailPath, + this.birthDate, + this.updatedAt, + }); + + final String id; + final DateTime? birthDate; + final bool isHidden; + final String name; + final String thumbnailPath; + final DateTime? updatedAt; +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 4e847ea022..828a7b2398 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -3,4 +3,6 @@ import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IUserRepository { Future> getByIds(List ids); Future get(String id); + Future> getAll({bool self = true}); + Future update(User user); } diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/interfaces/user_api.interface.dart new file mode 100644 index 0000000000..67ac3c0883 --- /dev/null +++ b/mobile/lib/interfaces/user_api.interface.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserApiRepository { + Future> getAll(); + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }); +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 6a7c612b15..297a819b6a 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { String? country; @@ -235,7 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -258,7 +258,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index d8ea7cd89b..1434d1cca5 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; @@ -30,7 +31,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; -import 'package:isar/isar.dart'; @RoutePage() // ignore: must_be_immutable @@ -73,7 +73,7 @@ class GalleryViewerPage extends HookConsumerWidget { : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == Isar.autoIncrement; + final isFromDto = currentAsset.id == noDbId; Asset asset = stackIndex.value == -1 ? currentAsset diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index acabc75aa4..2ca2a37918 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -19,7 +20,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; -import 'package:openapi/api.dart'; @RoutePage() class SearchInputPage extends HookConsumerWidget { @@ -110,7 +110,7 @@ class SearchInputPage extends HookConsumerWidget { } showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 8e5ef43260..d42b2a39e4 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; +String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 79856c525b..16a3c0e81b 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart'; // ************************************************************************** String _$activityStatisticsHash() => - r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + r'1f43f0bcb11c754ca3cb586a13570db25023b9a8'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 77518f47d0..fe8a1fccce 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return userService.getUsersInDb(); + return userService.getUsers(); }); diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index eff7b4b68e..23a570d1c8 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8'; +String _$mapStateNotifierHash() => r'22e4e571bd0730dbc34b109255a62b920e9c7d66'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index e2c243354b..7c956f0a37 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,14 +1,14 @@ +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/person.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( GetAllPeopleRef ref, ) async { final PersonService personService = ref.read(personServiceProvider); diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index db2edfb956..c5ff6287cd 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,12 +6,11 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd'; +String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = - AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -20,7 +19,7 @@ final getAllPeopleProvider = allTransitiveDependencies: null, ); -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index c6012af371..087344302a 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -1,6 +1,11 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -69,6 +74,12 @@ class AssetRepository implements IAssetRepository { return query.limit(limit).findAll(); } + @override + Future> updateAll(List assets) async { + await _db.writeTxn(() => _db.assets.putAll(assets)); + return assets; + } + @override Future> getMatches({ required List assets, @@ -86,6 +97,20 @@ class AssetRepository implements IAssetRepository { } return _getMatchesImpl(query, ownerId, assets, limit); } + + @override + Future> getDeviceAssetsById(List ids) => + Platform.isAndroid + ? _db.androidDeviceAssets.getAll(ids.cast()) + : _db.iOSDeviceAssets.getAllById(ids.cast()); + + @override + Future upsertDeviceAssets(List deviceAssets) => + _db.writeTxn( + () => Platform.isAndroid + ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) + : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 3ad0e1cba0..eb796f6c6b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -6,14 +6,18 @@ import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( - (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider).assetsApi, + ref.watch(apiServiceProvider).searchApi, + ), ); class AssetApiRepository extends BaseApiRepository implements IAssetApiRepository { final AssetsApi _api; + final SearchApi _searchApi; - AssetApiRepository(this._api); + AssetApiRepository(this._api, this._searchApi); @override Future update(String id, {String? description}) async { @@ -22,4 +26,27 @@ class AssetApiRepository extends BaseApiRepository ); return Asset.remote(response); } + + @override + Future> search({List personIds = const []}) async { + // TODO this always fetches all assets, change API and usage to actually do pagination + final List result = []; + bool hasNext = true; + int currentPage = 1; + while (hasNext) { + final response = await checkNull( + _searchApi.searchMetadata( + MetadataSearchDto( + personIds: personIds, + page: currentPage, + size: 1000, + ), + ), + ); + result.addAll(response.assets.items.map(Asset.remote)); + hasNext = response.assets.nextPage != null; + currentPage++; + } + return result; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart new file mode 100644 index 0000000000..3419a2bc77 --- /dev/null +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final partnerApiRepositoryProvider = Provider( + (ref) => PartnerApiRepository( + ref.watch(apiServiceProvider).partnersApi, + ), +); + +class PartnerApiRepository extends BaseApiRepository + implements IPartnerApiRepository { + final PartnersApi _api; + + PartnerApiRepository(this._api); + + @override + Future> getAll(Direction direction) async { + final response = await checkNull( + _api.getPartners( + direction == Direction.sharedByMe + ? PartnerDirection.by + : PartnerDirection.with_, + ), + ); + return response.map(User.fromPartnerDto).toList(); + } + + @override + Future create(String id) async { + final dto = await checkNull(_api.createPartner(id)); + return User.fromPartnerDto(dto); + } + + @override + Future delete(String id) => checkNull(_api.removePartner(id)); + + @override + Future update(String id, {required bool inTimeline}) async { + final dto = await checkNull( + _api.updatePartner( + id, + UpdatePartnerDto(inTimeline: inTimeline), + ), + ); + return User.fromPartnerDto(dto); + } +} diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart new file mode 100644 index 0000000000..8071c33dc2 --- /dev/null +++ b/mobile/lib/repositories/person_api.repository.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final personApiRepositoryProvider = Provider( + (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), +); + +class PersonApiRepository extends BaseApiRepository + implements IPersonApiRepository { + final PeopleApi _api; + + PersonApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.getAllPeople()); + return dto.people.map(_toPerson).toList(); + } + + @override + Future update(String id, {String? name}) async { + final dto = await checkNull( + _api.updatePerson(id, PersonUpdateDto(name: name)), + ); + return _toPerson(dto); + } + + static Person _toPerson(PersonResponseDto dto) => Person( + birthDate: dto.birthDate, + id: dto.id, + isHidden: dto.isHidden, + name: dto.name, + thumbnailPath: dto.thumbnailPath, + ); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index b05af9a57f..796b1f421b 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -20,4 +21,19 @@ class UserRepository implements IUserRepository { @override Future get(String id) => _db.users.getById(id); + + @override + Future> getAll({bool self = true}) { + if (self) { + return _db.users.where().findAll(); + } + final int userId = Store.get(StoreKey.currentUser).isarId; + return _db.users.where().isarIdNotEqualTo(userId).findAll(); + } + + @override + Future update(User user) async { + await _db.writeTxn(() => _db.users.put(user)); + return user; + } } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart new file mode 100644 index 0000000000..ffc50ae4c3 --- /dev/null +++ b/mobile/lib/repositories/user_api.repository.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final userApiRepositoryProvider = Provider( + (ref) => UserApiRepository( + ref.watch(apiServiceProvider).usersApi, + ), +); + +class UserApiRepository extends BaseApiRepository + implements IUserApiRepository { + final UsersApi _api; + + UserApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.searchUsers()); + return dto.map(User.fromSimpleUserDto).toList(); + } + + @override + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }) async { + final response = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return (profileImagePath: response.profileImagePath); + } +} diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 09030a621b..d06bc86d48 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -18,7 +18,9 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; @@ -30,7 +32,6 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -362,16 +363,20 @@ class BackgroundService { apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); - PartnerService partnerService = PartnerService(apiService, db); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); UserRepository userRepository = UserRepository(db); + UserApiRepository userApiRepository = + UserApiRepository(apiService.usersApi); AlbumApiRepository albumApiRepository = AlbumApiRepository(apiService.albumsApi); - HashService hashService = HashService(db, this, albumMediaRepository); + PartnerApiRepository partnerApiRepository = + PartnerApiRepository(apiService.partnersApi); + HashService hashService = + HashService(assetRepository, this, albumMediaRepository); EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( @@ -381,8 +386,12 @@ class BackgroundService { albumMediaRepository, albumApiRepository, ); - UserService userService = - UserService(apiService, db, syncSerive, partnerService); + UserService userService = UserService( + partnerApiRepository, + userApiRepository, + userRepository, + syncSerive, + ); AlbumService albumService = AlbumService( userService, syncSerive, diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 94d680972f..3827e421e6 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -4,20 +4,24 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; class HashService { - HashService(this._db, this._backgroundService, this._albumMediaRepository); - final Isar _db; + HashService( + this._assetRepository, + this._backgroundService, + this._albumMediaRepository, + ); + final IAssetRepository _assetRepository; final BackgroundService _backgroundService; final IAlbumMediaRepository _albumMediaRepository; final _log = Logger('HashService'); @@ -55,7 +59,8 @@ class HashService { final ids = assets .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) .toList(); - final List hashes = await _lookupHashes(ids); + final List hashes = + await _assetRepository.getDeviceAssetsById(ids); final List toAdd = []; final List toHash = []; @@ -106,12 +111,6 @@ class HashService { return _getHashedAssets(assets, hashes); } - /// Lookup hashes of assets by their local ID - Future> _lookupHashes(List ids) => - Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); - /// Processes a batch of files and saves any successfully hashed /// values to the DB table. Future _processBatch( @@ -131,11 +130,7 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _db.writeTxn( - () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(validHashes.cast()) - : _db.iOSDeviceAssets.putAll(validHashes.cast()), - ); + await _assetRepository.upsertDeviceAssets(validHashes); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } @@ -168,7 +163,7 @@ class HashService { final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ref.watch(backgroundServiceProvider), ref.watch(albumMediaRepositoryProvider), ), diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index ea07f7c019..b95899df67 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,18 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ); }); @@ -20,9 +19,9 @@ class MemoryService { final log = Logger("MemoryService"); final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; - MemoryService(this._apiService, this._db); + MemoryService(this._apiService, this._assetRepository); Future?> getMemoryLane() async { try { @@ -39,7 +38,7 @@ class MemoryService { List memories = []; for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { final String title = yearsAgo <= 1 ? 'memories_year_ago'.tr() diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 8cd2fe424f..67d7f4e1d1 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,43 +1,33 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final partnerServiceProvider = Provider( (ref) => PartnerService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userRepositoryProvider), ), ); class PartnerService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); - PartnerService(this._apiService, this._db); - - Future?> getPartners(PartnerDirection direction) async { - try { - final userDtos = await _apiService.partnersApi.getPartners(direction); - if (userDtos != null) { - return userDtos.map((u) => User.fromPartnerDto(u)).toList(); - } - } catch (e) { - _log.warning("Failed to get partners for direction $direction", e); - } - return null; - } + PartnerService( + this._partnerApiRepository, + this._userRepository, + ); Future removePartner(User partner) async { try { - await _apiService.partnersApi.removePartner(partner.id); + await _partnerApiRepository.delete(partner.id); partner.isPartnerSharedBy = false; - await _db.writeTxn(() => _db.users.put(partner)); + await _userRepository.update(partner); } catch (e) { _log.warning("Failed to remove partner ${partner.id}", e); return false; @@ -47,12 +37,10 @@ class PartnerService { Future addPartner(User partner) async { try { - final dto = await _apiService.partnersApi.createPartner(partner.id); - if (dto != null) { - partner.isPartnerSharedBy = true; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + await _partnerApiRepository.create(partner.id); + partner.isPartnerSharedBy = true; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to add partner ${partner.id}", e); } @@ -61,13 +49,13 @@ class PartnerService { Future updatePartner(User partner, {required bool inTimeline}) async { try { - final dto = await _apiService.partnersApi - .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline)); - if (dto != null) { - partner.inTimeline = dto.inTimeline ?? partner.inTimeline; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + final dto = await _partnerApiRepository.update( + partner.id, + inTimeline: inTimeline, + ); + partner.inTimeline = dto.inTimeline; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to update partner ${partner.id}", e); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index ddb61f5e48..5b325acdc5 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,29 +1,37 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => - PersonService(ref.read(apiServiceProvider), ref.read(dbProvider)); +PersonService personService(PersonServiceRef ref) => PersonService( + ref.watch(personApiRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ref.read(assetRepositoryProvider), + ); class PersonService { final Logger _log = Logger("PersonService"); - final ApiService _apiService; - final Isar _db; + final IPersonApiRepository _personApiRepository; + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; - PersonService(this._apiService, this._db); + PersonService( + this._personApiRepository, + this._assetApiRepository, + this._assetRepository, + ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { - final peopleResponseDto = await _apiService.peopleApi.getAllPeople(); - return peopleResponseDto?.people ?? []; + return await _personApiRepository.getAll(); } catch (error, stack) { _log.severe("Error while fetching curated people", error, stack); return []; @@ -31,50 +39,19 @@ class PersonService { } Future> getPersonAssets(String id) async { - List result = []; - var hasNext = true; - var currentPage = 1; - try { - while (hasNext) { - final response = await _apiService.searchApi.searchMetadata( - MetadataSearchDto( - personIds: [id], - page: currentPage, - size: 1000, - ), - ); - - if (response == null) { - break; - } - - if (response.assets.nextPage == null) { - hasNext = false; - } - - final assets = response.assets.items; - final mapAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); - result.addAll(mapAssets); - - currentPage++; - } + final assets = await _assetApiRepository.search(personIds: [id]); + return await _assetRepository + .getAllByRemoteId(assets.map((a) => a.remoteId!)); } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - - return result; + return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { - return await _apiService.peopleApi.updatePerson( - id, - PersonUpdateDto( - name: name, - ), - ); + return await _personApiRepository.update(id, name: name); } catch (error, stack) { _log.severe("Error while updating person name", error, stack); } diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 01a5ed8f30..9a24069fbf 100644 --- a/mobile/lib/services/person.service.g.dart +++ b/mobile/lib/services/person.service.g.dart @@ -6,7 +6,7 @@ part of 'person.service.dart'; // RiverpodGenerator // ************************************************************************** -String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; +String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; /// See also [personService]. @ProviderFor(personService) diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index cf3905e5ca..336fe45010 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,27 +1,27 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); class SearchService { final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._db); + SearchService(this._apiService, this._assetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -103,7 +103,7 @@ class SearchService { return null; } - return _db.assets + return _assetRepository .getAllByRemoteId(response.assets.items.map((e) => e.id)); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 75074101c2..8bff21fef6 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; class StackService { - StackService(this._api, this._db); + StackService(this._api, this._assetRepository); final ApiService _api; - final Isar _db; + final IAssetRepository _assetRepository; Future getStack(String stackId) async { try { @@ -61,10 +61,7 @@ class StackService { removeAssets.add(asset); } - - _db.writeTxn(() async { - await _db.assets.putAll(removeAssets); - }); + await _assetRepository.updateAll(removeAssets); } catch (error) { debugPrint("Error while deleting stack: $error"); } @@ -74,6 +71,6 @@ class StackService { final stackServiceProvider = Provider( (ref) => StackService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 9631141c41..4c2b3cbbd0 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,68 +1,48 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/services/partner.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final userServiceProvider = Provider( (ref) => UserService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), + ref.watch(userRepositoryProvider), ref.watch(syncServiceProvider), - ref.watch(partnerServiceProvider), ), ); class UserService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; + final IUserRepository _userRepository; final SyncService _syncService; - final PartnerService _partnerService; final Logger _log = Logger("UserService"); UserService( - this._apiService, - this._db, + this._partnerApiRepository, + this._userApiRepository, + this._userRepository, this._syncService, - this._partnerService, ); - Future?> _getAllUsers() async { - try { - final dto = await _apiService.usersApi.searchUsers(); - return dto?.map(User.fromSimpleUserDto).toList(); - } catch (e) { - _log.warning("Failed get all users", e); - return null; - } - } + Future> getUsers({bool self = false}) => + _userRepository.getAll(self: self); - Future> getUsersInDb({bool self = false}) async { - if (self) { - return _db.users.where().findAll(); - } - final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); - } - - Future uploadProfileImage(XFile image) async { + Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { - return await _apiService.usersApi.createProfileImage( - MultipartFile.fromBytes( - 'file', - await image.readAsBytes(), - filename: image.name, - ), + return await _userApiRepository.createProfileImage( + name: image.name, + data: await image.readAsBytes(), ); } catch (e) { _log.warning("Failed to upload profile image", e); @@ -71,13 +51,19 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(); - final List? sharedBy = - await _partnerService.getPartners(PartnerDirection.by); - final List? sharedWith = - await _partnerService.getPartners(PartnerDirection.with_); + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); - if (users == null || sharedBy == null || sharedWith == null) { + if (users == null) { _log.warning("Failed to refresh users"); return null; } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index e7a1b9e39e..9fc7b13eed 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,7 +1,7 @@ +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; String getThumbnailUrl( @@ -61,7 +61,7 @@ String getOriginalUrlForRemoteId(final String id) { String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; return '${isFromDto ? asset.remoteId : asset.id}_fullStage'; } diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 8e818f64fb..6cadef763d 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; -import 'package:isar/isar.dart'; class ThumbnailImage extends ConsumerWidget { /// The asset to show the thumbnail image for @@ -46,7 +46,7 @@ class ThumbnailImage extends ConsumerWidget { ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; Widget buildSelectionIcon(Asset asset) { if (isSelected) { diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index d79ae5bd95..dfc435c807 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -3,23 +3,23 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { var imageSize = 45.0; final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return people.widgetWhen( onData: (people) { From f031c096870e75e8a31ca357935fd8a24273613f Mon Sep 17 00:00:00 2001 From: JonOcto <22536384+JonOcto@users.noreply.github.com> Date: Wed, 25 Sep 2024 00:18:07 +1000 Subject: [PATCH 051/599] fix(docs): typo in remote-access.md (#12895) Fixed typo in remote-access.md Fixed spelling of "tutorial". --- docs/docs/guides/remote-access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index 1ea068c3a0..6f401dfc5a 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -27,7 +27,7 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). -:::tip Video toturial +:::tip Video tutorial You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. ::: From b85d8943e7ce65f826e2c56d8a23922994dc22fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:36:25 -0400 Subject: [PATCH 052/599] chore(deps): update base-image to v20240924 (major) (#12893) chore(deps): update base-image to v20240924 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 64dcab758b..66965c0edb 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240917@sha256:3d92952d37cd68f5bf641aa80e5cc034e0d11f3774147f5db8db93138cfa5b3b AS dev +FROM ghcr.io/immich-app/base-server-dev:20240924@sha256:fff4358d435065a626c64a4c015cbfce6ee714b05fabe39aa0d83d8cff3951f2 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240917@sha256:67a40250f03812fe1e6f6b6345a3c7b71b3a9f24c65ed4862e82be8b3e53d23a +FROM ghcr.io/immich-app/base-server-prod:20240924@sha256:af3089fe48d7ff162594bd7edfffa56ba4e7014ad10ad69c4ebfd428e39b06ff WORKDIR /usr/src/app ENV NODE_ENV=production \ From af8f3774d0f6dc582c4a8449e315b18d629d68cf Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:38:13 -0400 Subject: [PATCH 053/599] docs: details for windows users how to change docker volume (#12551) * details for windows users * Update requirements.md --- docs/docs/install/docker-compose.mdx | 1 + docs/docs/install/environment-variables.md | 28 ++++++------------- docs/docs/install/requirements.md | 32 ++++++++++++++++++++-- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index a3bd703a01..b73d51b4d2 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -58,6 +58,7 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. +- Set your timezone by uncommenting the `TZ=` line. ### Step 3 - Start the containers diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a0cf71e044..3944f6755b 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -27,23 +27,14 @@ If this should not work, try running `docker compose up -d --force-recreate`. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. ::: -### Supported filesystems - -The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group -ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. -It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). -If this is an issue, you can change the bind mount to a Docker volume instead. - -Regardless of filesystem, it is not recommended to use a network share for your database location due to performance and possible data loss issues. - ## General | Variable | Description | Default | Containers | Workers | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | | server | microservices | +| `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*1⚠️ | `./upload`\*2 | server | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | @@ -52,16 +43,13 @@ Regardless of filesystem, it is not recommended to use a network share for your | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | -\*1: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. - -\*2: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. -It only need to be set if the Immich deployment method is changing. - -:::tip -`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. - +\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -::: + +\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. + +\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. +It only need to be set if the Immich deployment method is changing. ## Workers diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 88d85c7bee..b96705203a 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -23,7 +23,33 @@ Immich requires the command `docker compose` - the similarly named `docker-compo - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - - This can present an issue for Windows users. See [here](/docs/install/environment-variables#supported-filesystems) - for more details and alternatives. + - This can present an issue for Windows users. See below for details and an alternative setup. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - - Network shares are supported for the storage of image and video assets only. + - Network shares are supported for the storage of image and video assets only. It is not recommended to use a network share for your database location due to performance and possible data loss issues. + +### Special requirements for Windows users + +
+Database storage on Windows systems + +The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group +ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. +It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). +If this is an issue, you can change the bind mount to a Docker volume instead as follows: + +Make the following change to `.env`: + +```diff +- DB_DATA_LOCATION=./postgres ++ DB_DATA_LOCATION=pgdata +``` + +Add the following line to the bottom of `docker-compose.yml`: + +```diff +volumes: + model-cache: ++ pgdata: +``` + +
From b45fce8ddf773f9e4033d4819de18ea85603209b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:13:37 +0200 Subject: [PATCH 054/599] fix: album title state weirdness (#12874) --- web/src/lib/components/album-page/album-title.svelte | 7 ++++--- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 22c26aa10c..1e69ecf1a3 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -7,6 +7,7 @@ export let id: string; export let albumName: string; export let isOwned: boolean; + export let onUpdate: (albumName: string) => void; $: newAlbumName = albumName; @@ -16,17 +17,17 @@ } try { - await updateAlbumInfo({ + ({ albumName } = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName, }, - }); + })); + onUpdate(albumName); } catch (error) { handleError(error, $t('errors.unable_to_save_album')); return; } - albumName = newAlbumName; }; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index cbdb38192e..b11bf9b8aa 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -589,7 +589,12 @@ {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
- + (album.albumName = albumName)} + /> {#if album.assetCount > 0} From 05d8c4c132b08052293ec6e45b8a1ebe7a2eb8e6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 24 Sep 2024 17:53:57 -0400 Subject: [PATCH 055/599] fix: do not use trashed assets as album covers (#12905) --- server/src/queries/album.repository.sql | 27 ++++++++++--------- server/src/repositories/album.repository.ts | 30 +++++++++------------ 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index cc052e9de6..c4f6fbdd32 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -483,16 +483,13 @@ UPDATE "albums" SET "albumThumbnailAssetId" = ( SELECT - "albums_assets2"."assetsId" + "album_assets"."assetsId" FROM - "assets" "assets", - "albums_assets_assets" "albums_assets2" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - ( - "albums_assets2"."assetsId" = "assets"."id" - AND "albums_assets2"."albumsId" = "albums"."id" - ) - AND ("assets"."deletedAt" IS NULL) + "album_assets"."albumsId" = "albums"."id" ORDER BY "assets"."fileCreatedAt" DESC LIMIT @@ -505,17 +502,21 @@ WHERE SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" + "album_assets"."albumsId" = "albums"."id" ) OR "albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" + "album_assets"."albumsId" = "albums"."id" + AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 4101d78c8e..f7b4cb44aa 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -277,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql() async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); + const builder = this.dataSource + .createQueryBuilder('albums_assets_assets', 'album_assets') + .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') + .where('"album_assets"."albumsId" = "albums"."id"'); - const albumContainsThumbnail = albumHasAssets + const newThumbnail = builder .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + .select('"album_assets"."assetsId"') + .orderBy('"assets"."fileCreatedAt"', 'DESC') + .limit(1); + const hasAssets = builder.clone().select('1'); + const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"'); const updateAlbums = this.repository .createQueryBuilder('albums') .update(AlbumEntity) .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); const result = await updateAlbums.execute(); From 06f1376de38682a11fe18fb305ca579c7f54b804 Mon Sep 17 00:00:00 2001 From: Cary Keesler <44330591+carykees98@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:59:35 -0400 Subject: [PATCH 056/599] fix(web): Updated web README.md (#12899) Updated web README.md --- web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/README.md b/web/README.md index e9693ceb01..603c7ad64e 100644 --- a/web/README.md +++ b/web/README.md @@ -2,4 +2,4 @@ This project uses the [SvelteKit](https://kit.svelte.dev/) web framework. Please refer to [the SvelteKit docs](https://kit.svelte.dev/docs) for information on getting started as a contributor to this project. In particular, it will help you navigate the project's code if you understand the basics of [SvelteKit routing](https://kit.svelte.dev/docs/routing). -When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [../server](the server project). +When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [the server project](../server). From 46fe60693e309cf57eea72c397b3ecf1ba523783 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:56:02 -0400 Subject: [PATCH 057/599] chore(deps): update dependency @types/react to v18.3.8 (#12918) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index ba9f33dc1e..65e9df8d9e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -5506,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -18805,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "requires": { "@types/prop-types": "*", From 8d515adac517c4871b33fba48cf37e25580e96e3 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:04:53 -0400 Subject: [PATCH 058/599] feat(web): fixed combobox positioning (#12848) * fix(web): modal sticky bottom scrolling * chore: minor styling tweaks * wip: add portal so modals show on Safari in detail panel * feat: fixed position dropdown menu * chore: refactoring and cleanup * feat: zooming and virtual keyboard working for iPadOS/Safari * Revert "feat: zooming and virtual keyboard working for iPadOS/Safari" This reverts commit cac29bac0df9112cec1d4c66af82dd343081e08a. * wip: minor code cleanup * wip: recover from visual viewport changes * wip: ease in a little more visualviewport magic * wip: code cleanup * fix: only show dropdown above when viewport is zoomed out * fix: code review suggestions for code style Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * fix: better variable naming * chore: better documentation for the bottom breakpoint --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../asset-viewer/detail-panel-tags.svelte | 5 +- .../asset-viewer/detail-panel.svelte | 15 ++- .../shared-components/combobox.svelte | 116 +++++++++++++++++- .../full-screen-modal.svelte | 22 ++-- web/src/lib/i18n/en.json | 2 +- 5 files changed, 134 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 434682f73e..449f61183f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -1,6 +1,7 @@ +
{#if isOpen} @@ -228,7 +334,7 @@ role="option" aria-selected={selectedIndex === 0} aria-disabled={true} - class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" + class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} on:click={() => closeDropdown()} > @@ -240,7 +346,7 @@
  • handleSelect(option)} role="option" diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index b5b21f0c23..ececa25b1e 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -68,28 +68,24 @@ use:focusTrap >
    -
    +
    - {#if isStickyBottom} -
    - -
    - {/if}
    + {#if isStickyBottom} +
    + +
    + {/if}
  • diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index aaa3c77e2b..534ac08636 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1194,7 +1194,7 @@ "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", - "tag_not_found_question": "Cannot find a tag? Create one here", + "tag_not_found_question": "Cannot find a tag? Create a new tag.", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", From 005528ab5ec6514e2b93a52a5dbe43481821b733 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:05:03 -0400 Subject: [PATCH 059/599] fix(server): http error parsing on endpoints without a default response (#12927) --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/notifications_api.dart | 10 +- mobile/openapi/lib/api_client.dart | 2 + .../lib/model/test_email_response_dto.dart | 99 +++++++++++++++++++ open-api/immich-openapi-specs.json | 18 ++++ open-api/typescript-sdk/src/fetch-client.ts | 8 +- .../controllers/notification.controller.ts | 3 +- server/src/dtos/notification.dto.ts | 3 + .../src/services/notification.service.spec.ts | 5 - server/src/services/notification.service.ts | 12 +-- .../notification.repository.mock.ts | 2 +- web/src/lib/utils/handle-error.ts | 16 ++- 13 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 mobile/openapi/lib/model/test_email_response_dto.dart create mode 100644 server/src/dtos/notification.dto.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 285514e11c..b6b0897e8f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -448,6 +448,7 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index fc0224a8c2..d08b6fc521 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -260,6 +260,7 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index a3506b9bc1..0681d58247 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -48,10 +48,18 @@ class NotificationsApi { /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): - Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TestEmailResponseDto',) as TestEmailResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 828c0b9ed9..c62d1c5b2e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -574,6 +574,8 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TestEmailResponseDto': + return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': diff --git a/mobile/openapi/lib/model/test_email_response_dto.dart b/mobile/openapi/lib/model/test_email_response_dto.dart new file mode 100644 index 0000000000..33e6c042d8 --- /dev/null +++ b/mobile/openapi/lib/model/test_email_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TestEmailResponseDto { + /// Returns a new [TestEmailResponseDto] instance. + TestEmailResponseDto({ + required this.messageId, + }); + + String messageId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto && + other.messageId == messageId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (messageId.hashCode); + + @override + String toString() => 'TestEmailResponseDto[messageId=$messageId]'; + + Map toJson() { + final json = {}; + json[r'messageId'] = this.messageId; + return json; + } + + /// Returns a new [TestEmailResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TestEmailResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TestEmailResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TestEmailResponseDto( + messageId: mapValueOfType(json, r'messageId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TestEmailResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TestEmailResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TestEmailResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'messageId', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 99ea313063..1a070f126b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3491,6 +3491,13 @@ }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, "description": "" } }, @@ -12348,6 +12355,17 @@ }, "type": "object" }, + "TestEmailResponseDto": { + "properties": { + "messageId": { + "type": "string" + } + }, + "required": [ + "messageId" + ], + "type": "object" + }, "TimeBucketResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d1b88afabb..f2f946f262 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -656,6 +656,9 @@ export type SystemConfigSmtpDto = { replyTo: string; transport: SystemConfigSmtpTransportDto; }; +export type TestEmailResponseDto = { + messageId: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -2220,7 +2223,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: { export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/notifications/test-email", oazapfts.json({ ...opts, method: "POST", body: systemConfigSmtpDto diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 2772e93b5d..3dd72dd73a 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -13,7 +14,7 @@ export class NotificationController { @Post('test-email') @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) - sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { + sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } } diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts new file mode 100644 index 0000000000..34b3923580 --- /dev/null +++ b/server/src/dtos/notification.dto.ts @@ -0,0 +1,3 @@ +export class TestEmailResponseDto { + messageId!: string; +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 9ef1310bfb..a0b9436f75 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -616,11 +616,6 @@ describe(NotificationService.name, () => { await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); - it('should fail if email could not be sent', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); - }); - it('should send mail successfully', async () => { systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 4eef49c631..bdb23ce700 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; @@ -140,7 +140,7 @@ export class NotificationService { try { await this.notificationRepository.verifySmtp(dto.transport); } catch (error) { - throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error }); + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } const { server } = await this.configCore.getConfig({ withCache: false }); @@ -152,7 +152,7 @@ export class NotificationService { }, }); - await this.notificationRepository.sendEmail({ + const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', html, @@ -161,6 +161,8 @@ export class NotificationService { replyTo: dto.replyTo || dto.from, smtp: dto.transport, }); + + return { messageId }; } async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @@ -312,10 +314,6 @@ export class NotificationService { imageAttachments: data.imageAttachments, }); - if (!response) { - return JobStatus.FAILED; - } - this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); return JobStatus.SUCCESS; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 71975b429c..16862dc3d7 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked } from 'vitest'; export const newNotificationRepositoryMock = (): Mocked => { return { renderEmail: vitest.fn(), - sendEmail: vitest.fn(), + sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), verifySmtp: vitest.fn(), }; }; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 9ca5bc8773..a7e9a4340c 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -2,9 +2,21 @@ import { isHttpError } from '@immich/sdk'; import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; export function getServerErrorMessage(error: unknown) { - if (isHttpError(error)) { - return error.data?.message || error.message; + if (!isHttpError(error)) { + return; } + + // errors for endpoints without return types aren't parsed as json + let data = error.data; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch { + // Not a JSON string + } + } + + return data?.message || error.message; } export function handleError(error: unknown, message: string) { From 35e03c1d6fffc01703b4803100acda9267c3af7d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 25 Sep 2024 18:19:10 +0200 Subject: [PATCH 060/599] chore(web): update translations (#12737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: -J- Co-authored-by: Albert Stoynov Co-authored-by: Benjamin Gynther Co-authored-by: Bezruchenko Simon Co-authored-by: CanbiZ Co-authored-by: David Abner Ciuhan Co-authored-by: Dean Cvjetanović Co-authored-by: Denis Pacquier Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Hary Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: João Gonçalves Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Petri Hämäläinen Co-authored-by: Shawn Co-authored-by: Xo Co-authored-by: btpv Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: fmis13 Co-authored-by: gallegonovato Co-authored-by: phewi Co-authored-by: pyccl Co-authored-by: pyorot Co-authored-by: rrole Co-authored-by: 李奕寯 --- web/src/lib/i18n/bg.json | 14 +- web/src/lib/i18n/ca.json | 15 +- web/src/lib/i18n/cs.json | 7 + web/src/lib/i18n/de.json | 7 + web/src/lib/i18n/es.json | 7 + web/src/lib/i18n/et.json | 137 ++- web/src/lib/i18n/fi.json | 439 +++++++--- web/src/lib/i18n/fr.json | 31 +- web/src/lib/i18n/he.json | 7 + web/src/lib/i18n/hr.json | 275 ++++-- web/src/lib/i18n/hu.json | 137 ++- web/src/lib/i18n/id.json | 7 + web/src/lib/i18n/lv.json | 133 +-- web/src/lib/i18n/nl.json | 8 + web/src/lib/i18n/pt.json | 1238 ++++++++++++++------------- web/src/lib/i18n/ro.json | 86 +- web/src/lib/i18n/ru.json | 7 + web/src/lib/i18n/sr_Cyrl.json | 7 + web/src/lib/i18n/sr_Latn.json | 9 +- web/src/lib/i18n/uk.json | 7 + web/src/lib/i18n/vi.json | 7 + web/src/lib/i18n/zh_Hant.json | 40 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 18 +- 23 files changed, 1653 insertions(+), 990 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index 29ac04eda8..f069bec6b3 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -12,19 +12,19 @@ "add_a_description": "Добави описание", "add_a_location": "Добави местоположение", "add_a_name": "Добави име", - "add_a_title": "Добави заглавие", + "add_a_title": "Добавете заглавие", "add_exclusion_pattern": "Добави модел за изключване", "add_import_path": "Добави път за импортиране", - "add_location": "Добави местоположение", - "add_more_users": "Добави още потребители", - "add_partner": "Добави партньор", + "add_location": "Добавете местоположение", + "add_more_users": "Добавете още потребители", + "add_partner": "Добавете партньор", "add_path": "Добави път", - "add_photos": "Добави снимки", + "add_photos": "Добавете снимки", "add_to": "Добави към...", "add_to_album": "Добави към албум", "add_to_shared_album": "Добави към споделен албум", - "added_to_archive": "Добавено в архива", - "added_to_favorites": "Добавено към любими", + "added_to_archive": "Добавено към архива", + "added_to_favorites": "Добавени към любимите ви", "added_to_favorites_count": "Добавени {count, number} към любими", "admin": { "add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index e9c695f79a..518c0abadf 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -8,7 +8,7 @@ "active": "Actiu", "activity": "Activitat", "activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}", - "add": "Afig", + "add": "Afegir", "add_a_description": "Afegiu una descripció", "add_a_location": "Afegiu una ubicació", "add_a_name": "Afegir un nom", @@ -41,6 +41,7 @@ "confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota", "confirm_reprocess_all_faces": "Esteu segur 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}?", + "create_job": "Crear tasca", "crontab_guru": "Crontab Guru", "disable_login": "Deshabiliteu l'inici de sessió", "disabled": "Deshabilitat", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolució de la miniatura", "image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.", "job_concurrency": "{job} concurrència", + "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", @@ -198,6 +200,7 @@ "password_settings": "Inici de sessió amb contrasenya", "password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya", "paths_validated_successfully": "Tots els camins han estat validats amb èxit", + "person_cleanup_job": "Neteja de persona", "quota_size_gib": "Tamany de la quota (GiB)", "refreshing_all_libraries": "Actualitzant totes les biblioteques", "registration": "Registre d'administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", "scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats", "scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous", + "search_jobs": "Tasques de cerca...", "send_welcome_email": "Enviar correu electrònic de benvinguda", "server_external_domain_settings": "Domini extern", "server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats", "storage_template_user_label": "{label} és l'etiqueta d'emmagatzematge de l'usuari", "system_settings": "Configuració del sistema", + "tag_cleanup_job": "Neteja d'etiqueta", "theme_custom_css_settings": "CSS personalitzat", "theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.", "theme_settings": "Configuració del tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Gestiona la configuració de la paperera", "untracked_files": "Fitxers sense seguiment", "untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error", + "user_cleanup_job": "Neteja d'usuari", "user_delete_delay": "El compte i els recursos de {user} es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.", "user_delete_delay_settings": "Retard de la supressió", "user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.", @@ -925,7 +931,7 @@ "offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.", "ok": "D'acord", "oldest_first": "El més vell primer", - "onboarding": "Onboarding", + "onboarding": "Incorporació", "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", @@ -1113,7 +1119,7 @@ "search_albums": "Buscar àlbums", "search_by_context": "Buscar per context", "search_by_filename": "Cerca per nom de fitxer o extensió", - "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", + "search_by_filename_example": "per exemple IMG_1234.JPG o PNG", "search_camera_make": "Buscar per fabricant de càmara...", "search_camera_model": "Buscar per model de càmera...", "search_city": "Buscar per ciutat...", @@ -1124,6 +1130,7 @@ "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", + "search_settings": "Configuració de cerca", "search_state": "Buscar per regió...", "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", @@ -1240,7 +1247,7 @@ "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", "tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una aquí", "tag_updated": "Etiqueta actualizada: {tag}", - "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index c2d7bce0e5..8c262f890b 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"", "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}?", + "create_job": "Vytvořit úlohu", "crontab_guru": "Crontab Guru", "disable_login": "Zakázat přihlášení", "disabled": "Zakázáno", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rozlišení miniatur", "image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.", "job_concurrency": "Souběžnost {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", @@ -198,6 +200,7 @@ "password_settings": "Přihlášení heslem", "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", "quota_size_gib": "Velikost kvóty (GiB)", "refreshing_all_libraries": "Obnovení všech knihoven", "registration": "Registrace správce", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", "scanning_library_for_changed_files": "Hledání změněných souborů v knihovně", "scanning_library_for_new_files": "Hledání nových souborů v knihovně", + "search_jobs": "Hledat úlohy...", "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů", "storage_template_user_label": "{label} je štítek úložiště uživatele", "system_settings": "Systémová nastavení", + "tag_cleanup_job": "Promazání značek", "theme_custom_css_settings": "Vlastní CSS", "theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.", "theme_settings": "Motivy", @@ -312,6 +317,7 @@ "trash_settings_description": "Správa nastavení koše", "untracked_files": "Neznámé soubory", "untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", + "user_cleanup_job": "Promazání uživatelů", "user_delete_delay": "Účet a položky uživatele {user} budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.", "user_delete_delay_settings": "Odložení odstranění", "user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.", @@ -1142,6 +1148,7 @@ "search_options": "Možnosti vyhledávání", "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", + "search_settings": "Hledat nastavení", "search_state": "Vyhledat stát...", "search_tags": "Vyhledávat značky...", "search_timezone": "Vyhledat časové pásmo...", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 352006ef6e..3ef036b7b0 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -41,6 +41,7 @@ "confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst", "confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.", "confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?", + "create_job": "Job erstellen", "crontab_guru": "Crontab Guru", "disable_login": "Login deaktvieren", "disabled": "Deaktiviert", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Miniaturansichts-Auflösung", "image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", "job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)", + "job_created": "Job erstellt", "job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.", "job_settings": "Job-Einstellungen", "job_settings_description": "Gleichzeitige Job-Prozessen verwalten", @@ -198,6 +200,7 @@ "password_settings": "Passwort Login", "password_settings_description": "Passwort-Anmeldeeinstellungen verwalten", "paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert", + "person_cleanup_job": "Personen aufräumen", "quota_size_gib": "Kontingent (GiB)", "refreshing_all_libraries": "Alle Bibliotheken aktualisieren", "registration": "Admin-Registrierung", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen", "scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien", "scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien", + "search_jobs": "Jobs suchen...", "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", "storage_template_user_label": "{label} is das Speicher-Label des Benutzers", "system_settings": "Systemeinstellungen", + "tag_cleanup_job": "Tags aufräumen", "theme_custom_css_settings": "Benutzerdefiniertes CSS", "theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.", "theme_settings": "Theme-Einstellungen", @@ -312,6 +317,7 @@ "trash_settings_description": "Papierkorb-Einstellungen verwalten", "untracked_files": "Unverfolgte Dateien", "untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", + "user_cleanup_job": "Benutzer aufräumen", "user_delete_delay": "Das Konto und die Dateien von {user} werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.", "user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern", "user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.", @@ -1141,6 +1147,7 @@ "search_options": "Suchoptionen", "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", + "search_settings": "Suche nach Einstellungen", "search_state": "Suche nach Bundesland / Provinz...", "search_tags": "Sache nach Tags...", "search_timezone": "Suche nach Zeitzone...", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 0136319192..0c77b9cfe1 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -41,6 +41,7 @@ "confirm_email_below": "Para confirmar, escribe \"{email}\" debajo", "confirm_reprocess_all_faces": "¿Estás seguro de que quieres volver a procesar todas las caras? Esto también eliminará las personas a las que le hayas asignado nombre.", "confirm_user_password_reset": "¿Estás seguro de que quieres resetear la contraseña de {user}?", + "create_job": "Crear trabajo", "crontab_guru": "Crontab Guru", "disable_login": "Deshabilitar inicio de sesión", "disabled": "Deshabilitado", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolución de las miniaturas", "image_thumbnail_resolution_description": "Se utiliza para ver grupos de fotos (cronología, vista de álbum, etc.). Las resoluciones más altas pueden conservar más detalles, pero tardan más en codificarse, tienen archivos de mayor tamaño y pueden reducir la reactividad de la aplicación.", "job_concurrency": "{job}: Procesos simultáneos", + "job_created": "Trabajo creado", "job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.", "job_settings": "Configuración tareas", "job_settings_description": "Administrar tareas simultáneas", @@ -198,6 +200,7 @@ "password_settings": "Contraseña de Acceso", "password_settings_description": "Administrar la configuración de inicio de sesión con contraseña", "paths_validated_successfully": "Todas las carpetas se han validado satisfactoriamente", + "person_cleanup_job": "Limpieza de personas", "quota_size_gib": "Tamaño de Quota (GiB)", "refreshing_all_libraries": "Actualizar todas las bibliotecas", "registration": "Registrar administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablecer la configuración a la configuración guardada recientemente", "scanning_library_for_changed_files": "Escanear archivos modificados en biblioteca", "scanning_library_for_new_files": "Escanear nuevos archivos en biblioteca", + "search_jobs": "Buscar trabajo...", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Administre la estructura de carpetas y el nombre de archivo del recurso cargado", "storage_template_user_label": "{label} es la etiqueta de almacenamiento del usuario", "system_settings": "Ajustes del Sistema", + "tag_cleanup_job": "Limpieza de etiquetas", "theme_custom_css_settings": "CSS Personalizado", "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes Tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", + "user_cleanup_job": "Limpieza de usuarios", "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", @@ -1141,6 +1147,7 @@ "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", + "search_settings": "Ajustes de la búsqueda", "search_state": "Buscar región/estado...", "search_tags": "Buscando etiquetas...", "search_timezone": "Buscar zona horaria...", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 49b60cd052..58a9ce024c 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -41,20 +41,22 @@ "confirm_email_below": "Kinnitamiseks sisesta allpool \"{email}\"", "confirm_reprocess_all_faces": "Kas oled kindel, et soovid kõik näod uuesti töödelda? See eemaldab kõik nimega isikud.", "confirm_user_password_reset": "Kas oled kindel, et soovid kasutaja {user} parooli lähtestada?", + "create_job": "Lisa tööde", "disable_login": "Keela sisselogimine", - "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et tuvastada sarnaseid pilte. Kasutab nutiotsingut", + "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et leida sarnaseid pilte. Kasutab nutiotsingut", "exclusion_pattern_description": "Välistamismustrid võimaldavad ignoreerida faile ja kaustu kogu skaneerimisel. See on kasulik, kui sul on kaustu, mis sisaldavad faile, mida sa ei soovi importida, nagu RAW failid.", "external_library_created_at": "Väline kogu (lisatud {date})", "external_library_management": "Väliste kogude haldus", - "face_detection": "Näotuvastus", - "face_detection_description": "Otsi üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Leitud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", - "facial_recognition_job_description": "Grupeeri leitud näod inimesteks. See samm käivitub siis, kui näotuvastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", + "face_detection": "Näoavastus", + "face_detection_description": "Avasta üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Avastatud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", + "facial_recognition_job_description": "Grupeeri avastatud näod inimesteks. See samm käivitub siis, kui näoavastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", "failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}", "force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik üksused. Seda ei saa tagasi võtta ja faile ei saa taastada.", "forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine", "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", "image_prefer_embedded_preview": "Eelista manustatud eelvaadet", "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", + "image_prefer_wide_gamut": "Eelista laia värvigammat", "image_prefer_wide_gamut_setting_description": "Kasuta pisipiltide jaoks Display P3. See säilitab paremini laia värviruumiga piltide erksuse, aga vanematel seadmetel ja vanemate brauseritega võivad pildid teistsugused välja näha. sRGB pildid säilitatakse värvinihete vältimiseks.", "image_preview_format": "Eelvaate formaat", "image_preview_resolution": "Eelvaate resolutsioon", @@ -67,9 +69,13 @@ "image_thumbnail_resolution": "Pisipildi resolutsioon", "image_thumbnail_resolution_description": "Kasutusel fotode mitmekaupa vaatamisel (ajajoon, albumi vaade, jne). Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", "job_concurrency": "{job} samaaegsus", + "job_created": "Tööde lisatud", + "job_not_concurrency_safe": "Seda töödet pole ohutu samaaegselt käivitada.", "job_settings": "Tööte seaded", "job_settings_description": "Halda töödete samaaegsust", "job_status": "Tööte seisund", + "jobs_delayed": "{jobCount, plural, other {# edasi lükatud}}", + "jobs_failed": "{jobCount, plural, other {# ebaõnnestus}}", "library_created": "Lisatud kogu: {library}", "library_cron_expression": "Cron avaldis", "library_cron_expression_description": "Sea skaneerimise intervall cron formaadis. Rohkema info jaoks vaata nt. Crontab Guru", @@ -81,6 +87,7 @@ "library_scanning_enable_description": "Luba kogu perioodiline skaneerimine", "library_settings": "Väline kogu", "library_settings_description": "Halda välise kogu seadeid", + "library_tasks_description": "Soorita kogu toiminguid", "library_watching_enable_description": "Jälgi välises kogus failide muudatusi", "library_watching_settings": "Kogu jälgimine (EKSPERIMENTAALNE)", "library_watching_settings_description": "Jälgi automaatselt muutunud faile", @@ -89,23 +96,26 @@ "logging_settings": "Logimine", "machine_learning_clip_model": "CLIP mudel", "machine_learning_clip_model_description": "CLIP mudeli nimi, mis on loetletud siin. Pane tähele, et mudeli muutmisel pead kõigi piltide peal nutiotsingu tööte uuesti käivitama.", - "machine_learning_duplicate_detection": "Duplikaatide tuvastus", - "machine_learning_duplicate_detection_enabled": "Luba duplikaatide tuvastus", + "machine_learning_duplicate_detection": "Duplikaatide leidmine", + "machine_learning_duplicate_detection_enabled": "Luba duplikaatide leidmine", "machine_learning_duplicate_detection_enabled_description": "Kui keelatud, dedubleeritakse siiski täpselt identsed üksused.", "machine_learning_duplicate_detection_setting_description": "Kasuta CLIP-manuseid, et leida tõenäoliseid duplikaate", "machine_learning_enabled": "Luba masinõpe", "machine_learning_enabled_description": "Kui keelatud, lülitatakse kõik masinõppe funktsioonid välja, sõltumata allolevatest seadetest.", "machine_learning_facial_recognition": "Näotuvastus", - "machine_learning_facial_recognition_description": "Otsi, tuvasta ja grupeeri piltidel näod", + "machine_learning_facial_recognition_description": "Avasta, tuvasta ja grupeeri piltidel näod", "machine_learning_facial_recognition_model": "Näotuvastuse mudel", - "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näotuvastuse tööde kõigi piltide peal uuesti käivitada.", + "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näoavastuse tööde kõigi piltide peal uuesti käivitada.", "machine_learning_facial_recognition_setting": "Luba näotuvastus", - "machine_learning_max_detection_distance": "Maksimaalne tuvastuskaugus", - "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused tuvastavad rohkem duplikaate, aga võivad anda valepositiivseid.", + "machine_learning_facial_recognition_setting_description": "Kui keelatud, siis ei kodeerita pilte näotuvastuse jaoks ning isikute sektsioon Avasta lehel jääb tühjaks.", + "machine_learning_max_detection_distance": "Maksimaalne avastuskaugus", + "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused leiavad rohkem duplikaate, aga võib esineda valepositiivseid.", + "machine_learning_max_recognition_distance": "Maksimaalne tuvastuskaugus", "machine_learning_max_recognition_distance_description": "Maksimaalne kaugus kahe näo vahel, mida tuleks lugeda samaks isikuks, vahemikus 0-2. Selle vähendamine aitab vältida erinevate inimeste samaks isikuks märkimist ja tõstmine aitab vältida sama inimese kaheks erinevaks isikuks märkimist. Pane tähele, et kaht isikut ühendada on lihtsam kui üht isikut kaheks eraldada, seega võimalusel kasuta madalamat lävendit.", - "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo tuvastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", - "machine_learning_min_recognized_faces": "Minimaalne leitud nägude arv", - "machine_learning_min_recognized_faces_description": "Minimaalne leitud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", + "machine_learning_min_detection_score": "Minimaalne avastusskoor", + "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo avastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", + "machine_learning_min_recognized_faces": "Minimaalne tuvastatud nägude arv", + "machine_learning_min_recognized_faces_description": "Minimaalne tuvastatud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", "machine_learning_settings": "Masinõppe seaded", "machine_learning_settings_description": "Halda masinõppe funktsioone ja seadeid", "machine_learning_smart_search": "Nutiotsing", @@ -113,17 +123,30 @@ "machine_learning_smart_search_enabled": "Luba nutiotsing", "machine_learning_smart_search_enabled_description": "Kui keelatud, siis ei kodeerita pilte nutiotsingu jaoks.", "machine_learning_url_description": "Masinõppe serveri URL", + "manage_concurrency": "Halda samaaegsust", "manage_log_settings": "Halda logi seadeid", "map_dark_style": "Tume stiil", + "map_enable_description": "Luba kaardi funktsioonid", "map_gps_settings": "Kaardi ja GPS-i seaded", + "map_gps_settings_description": "Halda kaardi ja GPS-i (pöördgeokodeerimise) seadeid", + "map_implications": "Kaardifunktsioon kasutab välist kaarditeenust (tiles.immich.cloud)", "map_light_style": "Hele stiil", + "map_manage_reverse_geocoding_settings": "Halda pöördgeokodeerimise seadeid", + "map_reverse_geocoding": "Pöördgeokodeerimine", + "map_reverse_geocoding_enable_description": "Luba pöördgeokodeerimine", + "map_reverse_geocoding_settings": "Pöördgeokodeerimise seaded", "map_settings": "Kaart", "map_settings_description": "Halda kaardi seadeid", + "map_style_description": "Kaarditeema style.json URL", "metadata_extraction_job": "Metaandmete eraldamine", "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid, näod ja resolutsioon", + "metadata_faces_import_setting": "Luba nägude import", + "metadata_settings": "Metaandmete seaded", + "metadata_settings_description": "Halda metaandmete seadeid", "migration_job": "Migratsioon", "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", + "note_unlimited_quota": "Märkus: Piiramatu kvoodi jaoks sisesta 0", "notification_email_from_address": "Saatja aadress", "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", @@ -140,17 +163,27 @@ "notification_enable_email_notifications": "Luba e-posti teel teavitused", "notification_settings": "Teavituse seaded", "notification_settings_description": "Halda teavituste seadeid, sh. e-posti teel", + "oauth_auto_launch": "Automaatne käivitamine", + "oauth_auto_launch_description": "Alusta OAuth autentimist automaatselt sisselogimise lehele jõudmisel", + "oauth_auto_register": "Automaatne registreerimine", + "oauth_auto_register_description": "Registreeri uued kasutajad automaatselt OAuth abil sisselogimisel", "oauth_button_text": "Nupu tekst", "oauth_client_id": "Kliendi ID", "oauth_client_secret": "Kliendi saladus", "oauth_enable_description": "Sisene OAuth abil", "oauth_issuer_url": "Väljastaja URL", + "oauth_mobile_redirect_uri": "Mobiilne ümbersuunamise URI", + "oauth_profile_signing_algorithm": "Profiili allkirjastamise algoritm", + "oauth_profile_signing_algorithm_description": "Algoritm, mida kasutatakse kasutajaprofiili allkirjastamiseks.", + "oauth_scope": "Skoop", "oauth_settings": "OAuth", "oauth_settings_description": "Halda OAuth sisselogimise seadeid", + "oauth_signing_algorithm": "Allkirjastamise algoritm", "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", "password_settings": "Parooliga sisselogimine", "password_settings_description": "Halda parooliga sisselogimise seadeid", "paths_validated_successfully": "Kõik teed edukalt valideeritud", + "person_cleanup_job": "Isikute korrastamine", "quota_size_gib": "Kvoot (GiB)", "refreshing_all_libraries": "Kõikide kogude värskendamine", "registration_description": "Kuna sa oled süsteemis esimene kasutaja, määratakse sind administraatoriks, ning sa saad lisada täiendavaid kasutajaid.", @@ -171,6 +204,10 @@ "storage_template_migration_info": "Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt olemasolevatele üksustele, käivita {job}.", "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", "system_settings": "Süsteemi seaded", + "tag_cleanup_job": "Siltide korrastamine", + "theme_custom_css_settings": "Kohandatud CSS", + "theme_custom_css_settings_description": "Cascading Style Sheets lubab Immich'i kujunduse kohandamist.", + "theme_settings": "Teema seaded", "theme_settings_description": "Halda Immich'i veebiliidese kohandamist", "thumbnail_generation_job": "Genereeri pisipildid", "thumbnail_generation_job_description": "Genereeri iga üksuse kohta suur, väike ja udustatud pisipilt ning iga isiku kohta pisipilt", @@ -229,13 +266,18 @@ "transcoding_video_codec_description": "VP9 on võimekas ja veebiga ühilduv, aga transkodeerimine võtab kauem aega. HEVC on sarnase jõudluse, aga mitte nii hea veebiga ühilduvusega. H.264 on laialt ühilduv ja transkodeerimine on kiire, aga tulemuseks on suuremad failid. AV1 on kõige võimekam koodek, aga pole vanematel seadmetel toetatud.", "trash_number_of_days": "Päevade arv", "trash_number_of_days_description": "Päevade arv, kui kaua hoida üksusi prügikastis enne nende lõplikku kustutamist", + "user_cleanup_job": "Kasutajate korrastamine", "user_delete_delay": "Kasutaja {user} konto ja üksuste lõplik kustutamine on planeeritud {delay, plural, one {# päeva} other {# päeva}} pärast.", "user_delete_delay_settings_description": "Päevade arv, pärast mida kustutatakse eemaldatud kasutaja konto ja üksused jäädavalt. Kasutajate kustutamise tööde käivitub keskööl, et otsida kustutamiseks valmis kasutajaid. Selle seadistuse muudatused rakenduvad järgmisel käivitumisel.", "user_delete_immediately": "Kasutaja {user} konto ja üksused suunatakse koheselt jäädavale kustutamisele.", "user_delete_immediately_checkbox": "Suuna kasutaja ja üksused jäädavale kustutamisele", + "user_management": "Kasutajate haldus", "user_password_has_been_reset": "Kasutaja parool on lähtestatud:", "user_password_reset_description": "Sisesta kasutajale ajutine parool ja teavita teda, et järgmisel sisselogimisel tuleb parool ära muuta.", "user_restore_description": "Kasutaja {user} konto taastatakse.", + "user_restore_scheduled_removal": "Taasta kasutaja - eemaldamine planeeritud {date, date, long}", + "user_settings": "Kasutajate seaded", + "user_settings_description": "Halda kasutajate seadeid", "user_successfully_removed": "Kasutaja {email} on eemaldatud.", "version_check_enabled_description": "Luba versioonikontroll", "version_check_implications": "Versioonikontroll vajab perioodilist ühendumist github.com-iga", @@ -270,11 +312,17 @@ "all_albums": "Kõik albumid", "all_people": "Kõik isikud", "all_videos": "Kõik videod", + "anti_clockwise": "Vastupäeva", + "api_key": "API võti", "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", + "api_key_empty": "Su API võtme nimi ei tohiks olla tühi", + "api_keys": "API võtmed", + "app_settings": "Rakenduse seaded", "archive": "Arhiiv", "archive_or_unarchive_photo": "Arhiveeri või taasta foto", "archive_size": "Arhiivi suurus", "archive_size_description": "Seadista arhiivi suurus allalaadimiseks (GiB)", + "are_these_the_same_person": "Kas need on sama isik?", "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", "asset_added_to_album": "Lisatud albumisse", "asset_adding_to_album": "Albumisse lisamine...", @@ -307,13 +355,19 @@ "bulk_delete_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta!", "bulk_keep_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} alles jätta? Sellega märgitakse kõik duplikaadigrupid lahendatuks ilma midagi kustutamata.", "bulk_trash_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid liigutatakse prügikasti.", + "buy": "Osta Immich", "camera": "Kaamera", "camera_brand": "Kaamera mark", "camera_model": "Kaamera mudel", "cancel": "Katkesta", + "cancel_search": "Katkesta otsing", "cannot_merge_people": "Ei saa isikuid ühendada", "cannot_undo_this_action": "Sa ei saa seda tagasi võtta!", "cannot_update_the_description": "Kirjelduse muutmine ebaõnnestus", + "change_date": "Muuda kuupäeva", + "change_expiration_time": "Muuda aegumisaega", + "change_location": "Muuda asukohta", + "change_name": "Muuda nime", "change_password": "Parooli muutmine", "change_password_description": "See on su esimene kord süsteemi siseneda, või on tehtud taotlus parooli muutmiseks. Palun sisesta allpool uus parool.", "change_your_password": "Muuda oma parooli", @@ -322,6 +376,10 @@ "check_logs": "Vaata logisid", "choose_matching_people_to_merge": "Vali kattuvad isikud, mida ühendada", "city": "Linn", + "clear": "Tühjenda", + "clear_all": "Tühjenda kõik", + "clear_all_recent_searches": "Tühjenda hiljutised otsingud", + "clear_value": "Tühjenda väärtus", "clockwise": "Päripäeva", "close": "Sulge", "color": "Värv", @@ -335,6 +393,7 @@ "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", "confirm_password": "Kinnita parool", "context": "Kontekst", + "continue": "Jätka", "copied_image_to_clipboard": "Pilt kopeeritud lõikelauale.", "copied_to_clipboard": "Kopeeritud lõikelauale!", "copy_error": "Kopeeri viga", @@ -383,7 +442,9 @@ "delete_user": "Kustuta kasutaja", "deleted_shared_link": "Jagatud link kustutatud", "description": "Kirjeldus", + "details": "Üksikasjad", "direction": "Suund", + "disallow_edits": "Keela muutmine", "discover": "Avasta", "display_options": "Kuva valikud", "display_original_photos_setting_description": "Eelista üksuse vaatamisel pisipildile algset fotot, kui see on veebiga ühilduv. See võib mõjutada fotode kuvamise kiirust.", @@ -454,6 +515,7 @@ "import_path_already_exists": "See imporditee on juba olemas.", "incorrect_email_or_password": "Vale e-posti aadress või parool", "profile_picture_transparent_pixels": "Profiilipildis ei tohi olla läbipaistvaid piksleid. Palun suumi sisse ja/või liiguta pilti.", + "quota_higher_than_disk_size": "Määratud kvoot on suurem kui kettamaht", "unable_to_add_album_users": "Kasutajate lisamine albumisse ebaõnnestus", "unable_to_add_assets_to_shared_link": "Üksuste jagatud lingile lisamine ebaõnnestus", "unable_to_add_comment": "Kommentaari lisamine ebaõnnestus", @@ -463,6 +525,7 @@ "unable_to_add_remove_archive": "{archived, select, true {Üksuse arhiivist taastamine} other {Üksuse arhiveerimine}} ebaõnnestus", "unable_to_add_remove_favorites": "Üksuse {favorite, select, true {lemmikuks lisamine} other {lemmikutest eemaldamine}} ebaõnnestus", "unable_to_archive_unarchive": "{archived, select, true {Arhiveerimine} other {Arhiivist taastamine}} ebaõnnestus", + "unable_to_change_album_user_role": "Kasutaja rolli albumis muutmine ebaõnnestus", "unable_to_change_date": "Kuupäeva muutmine ebaõnnestus", "unable_to_change_favorite": "Üksuse lemmiku staatuse muutmine ebaõnnestus", "unable_to_change_location": "Asukoha muutmine ebaõnnestus", @@ -536,23 +599,32 @@ "expired": "Aegunud", "expires_date": "Aegub {date}", "explore": "Avasta", + "export": "Ekspordi", "export_as_json": "Ekspordi JSON-formaati", "extension": "Laiend", + "external": "Väline", + "external_libraries": "Välised kogud", "face_unassigned": "Seostamata", "favorite": "Lemmik", "favorites": "Lemmikud", "feature_photo_updated": "Esiletõstetud foto muudetud", + "features": "Funktsioonid", + "features_setting_description": "Halda rakenduse funktsioone", "file_name": "Failinimi", "file_name_or_extension": "Failinimi või -laiend", "filename": "Failinimi", "filetype": "Failitüüp", "filter_people": "Filtreeri isikuid", + "find_them_fast": "Leia teda kiiresti nime järgi otsides", "folders": "Kaustad", "folders_feature_description": "Kaustavaate abil failisüsteemis olevate fotode ja videote sirvimine", "force_re-scan_library_files": "Sundskaneeri kogu kõik failid uuesti", "forward": "Edasi", "general": "Üldine", + "get_help": "Küsi abi", + "getting_started": "Alustamine", "go_back": "Tagasi", + "go_to_search": "Otsingusse", "group_albums_by": "Grupeeri albumid...", "group_no": "Ära grupeeri", "group_owner": "Grupeeri omaniku kaupa", @@ -575,6 +647,7 @@ "immich_logo": "Immich'i logo", "immich_web_interface": "Immich'i veebiliides", "import_from_json": "Impordi JSON-formaadist", + "in_albums": "{count, plural, one {# albumis} other {# albumis}}", "in_archive": "Arhiivis", "info": "Info", "interval": { @@ -595,9 +668,13 @@ "latest_version": "Uusim versioon", "latitude": "Laiuskraad", "leave": "Lahku", + "let_others_respond": "Luba teistel vastata", "library": "Kogu", "library_options": "Kogu seaded", + "light": "Hele", + "link_options": "Lingi valikud", "list": "Loend", + "loading": "Laadimine", "loading_search_results_failed": "Otsitulemuste laadimine ebaõnnestus", "log_out": "Logi välja", "log_out_all_devices": "Logi kõigist seadmetest välja", @@ -607,15 +684,19 @@ "logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?", "logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?", "longitude": "Pikkuskraad", + "look": "Välimus", "make": "Mark", "manage_shared_links": "Halda jagatud linke", "manage_sharing_with_partners": "Halda partneritega jagamist", + "manage_the_app_settings": "Halda rakenduse seadeid", "manage_your_account": "Halda oma kontot", "manage_your_api_keys": "Halda oma API võtmeid", "manage_your_devices": "Halda oma autenditud seadmeid", "map": "Kaart", "map_settings": "Kaardi seaded", + "media_type": "Meedia tüüp", "memories": "Mälestused", + "memories_setting_description": "Halda, mida sa oma mälestustes näed", "memory": "Mälestus", "menu": "Menüü", "merge": "Ühenda", @@ -642,17 +723,24 @@ "next_memory": "Järgmine mälestus", "no": "Ei", "no_albums_message": "Lisa album fotode ja videote organiseerimiseks", + "no_albums_with_name_yet": "Paistab, et sul pole veel ühtegi selle nimega albumit.", + "no_albums_yet": "Paistab, et sul pole veel ühtegi albumit.", "no_archived_assets_message": "Arhiveeri fotod ja videod, et neid Fotod vaatest peita", "no_assets_message": "KLIKI ESIMESE FOTO ÜLESLAADIMISEKS", "no_duplicates_found": "Ühtegi duplikaati ei leitud.", "no_exif_info_available": "Exif info pole saadaval", + "no_explore_results_message": "Oma kogu avastamiseks laadi üles rohkem fotosid.", "no_favorites_message": "Lisa lemmikud, et oma parimaid fotosid ja videosid kiiresti leida", "no_libraries_message": "Lisa väline kogu oma fotode ja videote vaatamiseks", + "no_results": "Vasteid pole", + "no_results_description": "Proovi sünonüümi või üldisemat märksõna", "no_shared_albums_message": "Lisa album, et fotosid ja videosid teistega jagada", + "notes": "Märkused", "notification_toggle_setting_description": "Luba e-posti teel teavitused", "notifications": "Teavitused", "notifications_setting_description": "Halda teavitusi", "oauth": "OAuth", + "ok": "Ok", "oldest_first": "Vanemad eespool", "onboarding_theme_description": "Vali oma serverile värviteema. Saad seda hiljem seadetes muuta.", "onboarding_welcome_user": "Tere tulemast, {user}", @@ -660,11 +748,13 @@ "only_refreshes_modified_files": "Värskendab ainult muudetud failid", "open_in_map_view": "Ava kaardi vaates", "open_in_openstreetmap": "Ava OpenStreetMap", + "open_the_search_filters": "Ava otsingufiltrid", "options": "Valikud", "or": "või", "organize_your_library": "Korrasta oma kogu", "original": "originaal", "other_devices": "Muud seadmed", + "other_variables": "Muud muutujad", "owned": "Minu omad", "owner": "Omanik", "partner": "Partner", @@ -699,6 +789,8 @@ "permanently_deleted_asset": "Üksus jäädavalt kustutatud", "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", "person": "Isik", + "person_hidden": "{name}{hidden, select, true { (peidetud)} other {}}", + "photo_shared_all_users": "Paistab, et oled oma fotosid kõigi kasutajatega jaganud, või pole ühtegi kasutajat, kellega jagada.", "photos": "Fotod", "photos_and_videos": "Fotod ja videod", "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotot}}", @@ -739,6 +831,8 @@ "purchase_panel_info_1": "Immich'i arendamine nõuab palju aega ja vaeva ning meie täiskohaga insenerid töötavad selle nimel, et teha see nii heaks kui vähegi võimalik. Meie missiooniks on muuta avatud lähtekoodiga tarkvara ja eetilised äritavad arendajatele jätkusuutlikuks sissetulekuallikaks ning luua privaatsust austav ökosüsteem, mis pakub tõelisi alternatiive ekspluatatiivsetele pilveteenustele.", "purchase_panel_info_2": "Kuna oleme otsustanud maksumüüre mitte lisada, ei anna see ost sulle Immich'is lisavõimalusi. Me loodame Immich'i jätkuvaks arenduseks sinusuguste kasutajate toetusele.", "purchase_panel_title": "Toeta projekti", + "purchase_per_server": "Serveri kohta", + "purchase_per_user": "Kasutaja kohta", "purchase_remove_product_key": "Eemalda tootevõti", "purchase_remove_product_key_prompt": "Kas oled kindel, et soovid tootevõtme eemaldada?", "purchase_remove_server_product_key": "Eemalda serveri tootevõti", @@ -747,10 +841,12 @@ "purchase_server_description_2": "Toetaja staatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Serveri tootevõtit haldab administraator", + "reaction_options": "Reaktsiooni valikud", "read_changelog": "Vaata muudatuste ülevaadet", "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", + "recent_searches": "Hiljutised otsingud", "refresh": "Värskenda", "refresh_encoded_videos": "Värskenda kodeeritud videod", "refresh_metadata": "Värskenda metaandmed", @@ -766,11 +862,14 @@ "remove_assets_title": "Eemalda üksused?", "remove_from_album": "Eemalda albumist", "remove_from_favorites": "Eemalda lemmikutest", + "remove_from_shared_link": "Eemalda jagatud lingist", "remove_user": "Eemalda kasutaja", "removed_api_key": "API võti eemaldatud: {name}", "removed_from_archive": "Arhiivist eemaldatud", "removed_from_favorites": "Lemmikutest eemaldatud", + "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", + "rename": "Nimeta ümber", "require_password": "Nõua parooli", "require_user_to_change_password_on_first_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", "reset": "Lähtesta", @@ -806,8 +905,10 @@ "search_for_existing_person": "Otsi olemasolevat isikut", "search_no_people": "Isikuid ei ole", "search_no_people_named": "Ei ole isikuid nimega \"{name}\"", + "search_options": "Otsingu valikud", "search_people": "Otsi inimesi", "search_places": "Otsi kohti", + "search_settings": "Otsingu seaded", "search_state": "Otsi osariiki...", "search_tags": "Otsi silte...", "search_timezone": "Otsi ajavööndit...", @@ -862,13 +963,18 @@ "show_metadata": "Kuva metaandmed", "show_or_hide_info": "Kuva või peida info", "show_password": "Kuva parooli", + "show_progress_bar": "Kuva edenemisriba", + "show_search_options": "Kuva otsingu valikud", "show_supporter_badge": "Toetaja märk", "show_supporter_badge_description": "Kuva toetaja märki", "sidebar": "Külgmenüü", + "sidebar_display_description": "Kuva külgmenüüs linki vaatele", "sign_out": "Logi välja", "sign_up": "Registreeru", "size": "Suurus", "skip_to_content": "Sisu juurde", + "skip_to_folders": "Kaustade juurde", + "skip_to_tags": "Siltide juurde", "slideshow": "Slaidiesitlus", "slideshow_settings": "Slaidiesitluse seaded", "sort_albums_by": "Järjesta albumid...", @@ -902,6 +1008,7 @@ "theme": "Teema", "theme_selection": "Teema valik", "theme_selection_description": "Sea automaatselt hele või tume teema vastavalt veebilehitseja eelistustele", + "time_based_memories": "Ajapõhised mälestused", "timezone": "Ajavöönd", "to_archive": "Arhiivi", "to_change_password": "Muuda parool", @@ -917,6 +1024,8 @@ "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", "unsaved_change": "Salvestamata muudatus", "updated_password": "Parool muudetud", + "upload": "Laadi üles", + "upload_concurrency": "Üleslaadimise samaaegsus", "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", "upload_skipped_duplicates": "{count, plural, one {# dubleeritud üksus} other {# dubleeritud üksust}} vahele jäetud", "upload_status_duplicates": "Duplikaadid", @@ -927,6 +1036,7 @@ "user": "Kasutaja", "user_id": "Kasutaja ID", "user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}", + "user_purchase_settings": "Osta", "user_purchase_settings_description": "Halda oma ostu", "username": "Kasutajanimi", "users": "Kasutajad", @@ -935,6 +1045,7 @@ "variables": "Muutujad", "version": "Versioon", "version_announcement_closing": "Sinu sõber, Alex", + "version_announcement_message": "Hei sõber, saadaval on rakenduse uus versioon. Palun võta aega, et lugeda väljalasketeadet ning veendu, et su docker-compose.yml ja .env failid on ajakohased, et vältida konfiguratsiooniprobleeme, eriti kui kasutad WatchTower'it või muud mehhanismi, mis rakendust automaatselt uuendab.", "video": "Video", "video_hover_setting": "Esita hõljutamisel video eelvaade", "video_hover_setting_description": "Esita video eelvaade, kui hiirt selle kohal hõljutada. Isegi kui keelatud, saab taasesituse alustada taasesitusnupu kohal hõljutades.", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index 15a3dc0a26..a6c07c18e9 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Lisää jaettuun albumiin", "added_to_archive": "Arkistoitu", "added_to_favorites": "Lisätty suosikkeihin", - "added_to_favorites_count": "{count} lisätty suosikkeihin", + "added_to_favorites_count": "{count, number} lisätty suosikkeihin", "admin": { "add_exclusion_pattern_description": "Lisää mallit, jonka mukaan jätetään tiedostoja pois. Jokerimerkit *, ** ja ? ovat tuettuna. Jättääksesi pois kaikki tiedostot mistä tahansa löytyvästä kansiosta \"Raw\" käytä \"**/Raw/**\". Jättääksesi pois kaikki \". tif\" päätteiset tiedot, käytä \"**/*.tif\". Jättääksesi pois tarkan tiedostopolun, käytä \"/path/to/ignore/**\".", "authentication_settings": "Autentikointiasetukset", @@ -41,6 +41,7 @@ "confirm_email_below": "Kirjota \"{email}\" vahvistaaksesi", "confirm_reprocess_all_faces": "Haluatko varmasti käsitellä uudelleen kaikki kasvot? Tämä poistaa myös nimetyt henkilöt.", "confirm_user_password_reset": "Haluatko varmasti nollata käyttäjän {user} salasanan?", + "create_job": "Luo tehtävä", "crontab_guru": "Crontab Guru", "disable_login": "Poista kirjautuminen käytöstä", "disabled": "Ei käytössä", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "Pikkukuvien resoluutio", "image_thumbnail_resolution_description": "Käytetään katsottaessa useita kuvia kerralla (aikajana, albuminäkymä, jne.) Korkeampi resoluutio antaa enemmän yksityiskohtia, mutta niiden luonti kestää kauemmin, tiedostokoot ovat isompia ja voivat heikentää sovelluksen responsiivisuutta.", "job_concurrency": "{job} yhtäaikaisuus", + "job_created": "Tehtävä luotu", "job_not_concurrency_safe": "Tätä tehtävää ei ole turvallista ajaa yhtäaikaisesti.", "job_settings": "Tehtävän asetukset", "job_settings_description": "Hallitse tehtävän samanaikaisuusasetuksia", "job_status": "Tehtävän tila", - "jobs_delayed": "{jobCount} tehtävää viivästetty", - "jobs_failed": "{jobCount} epäonnistui", + "jobs_delayed": "{jobCount, plural, other {# viivästynyttä}}", + "jobs_failed": "{jobCount, plural, other {# epäonnistunutta}}", "library_created": "Kirjasto {library} luotu", "library_cron_expression": "Cron-lauseke", "library_cron_expression_description": "Anna skannaustiheys cron-formaatissa. Saadaksesi lisätietoja katso esimerkiksi Crontab Guru", @@ -135,13 +137,13 @@ "map_reverse_geocoding": "Käänteinen Geokoodaus", "map_reverse_geocoding_enable_description": "Ota käyttöön osoitteiden poiminta karttakoordinaateista", "map_reverse_geocoding_settings": "Käänteisen Geokoodauksen asetukset", - "map_settings": "Kartta-asetukset", + "map_settings": "Kartta", "map_settings_description": "Hallitse kartan asetuksia", "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", - "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS, kasvot ja resoluutio", "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", - "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF -tiedoista ja kylkiäistiedostoista", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF- ja kylkiäistiedostoista", "metadata_settings": "Metatietoasetukset", "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", @@ -178,9 +180,9 @@ "oauth_issuer_url": "Toimitsijan URL", "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", "oauth_mobile_redirect_uri_override": "Ohita mobiilin uudelleenohjaus-URI", - "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun 'app.immich:/' -ohjausta ei tueta.", + "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun OAuth tarjoaja ei salli mobiili URI:a, kuten '{callback}'", "oauth_profile_signing_algorithm": "Profiilin allekirjoitusalgoritmi", - "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoituksessa", + "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoittamiseen.", "oauth_scope": "Skooppi (Scope)", "oauth_settings": "OAuth", "oauth_settings_description": "Hallitse OAuth kirjautumisen asetuksia", @@ -198,6 +200,7 @@ "password_settings": "Kirjaudu salasanalla", "password_settings_description": "Hallitse salasanakirjautumisen asetuksia", "paths_validated_successfully": "Kaikki polut validoitu", + "person_cleanup_job": "Henkilöpuhdistus", "quota_size_gib": "Kiintiön koko (Gt)", "refreshing_all_libraries": "Virkistetään kaikki kirjastot", "registration": "Pääkäyttäjän rekisteröinti", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset", "scanning_library_for_changed_files": "Etsitään kirjaston muuttuneita tiedostoja", "scanning_library_for_new_files": "Etsitään uusia tiedostoja", + "search_jobs": "Etsi tehtäviä...", "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Hallitse palvelimelle ladatun aineiston kansiorakennetta ja tiedostonimiä", "storage_template_user_label": "{label} on käyttäjän Tallennustilan Tunniste", "system_settings": "Järjestelmäasetukset", + "tag_cleanup_job": "Merkintäpuhdistus", "theme_custom_css_settings": "Mukautettu CSS", "theme_custom_css_settings_description": "Kustomoi Immichin ulkoasua Cascading Style Sheets:llä.", "theme_settings": "Teeman asetukset", @@ -265,7 +270,7 @@ "transcoding_codecs_learn_more": "Oppiaksesi lisää tässä käytetystä terminologiasta, tutustu FFmpeg- dokumentaatioon H.264 koodaaja, HEVC koodaaja sekä VP9 koodaaja.", "transcoding_constant_quality_mode": "Tasaisen laadun tyyppi", "transcoding_constant_quality_mode_description": "ICQ on parempi kuin CQP, mutta jotkut laitteistokiihdyttimet eivät tue sitä. Tätä asetusta käytetään oletuksena laatuun pohjautuvissa muunnoksissa, paitsi NVENC mikä ei tue ICQ:ta.", - "transcoding_constant_rate_factor": "", + "transcoding_constant_rate_factor": "Vakionopeustekijä", "transcoding_constant_rate_factor_description": "Videon laatu. Yleisimmät arvot ovat 23 H.264:lle, 28 HEVC:lle, 31 VP9:lle ja 35 AV1:lle. Matalampi arvo on parempi, mutta tekee isompia tiedostoja.", "transcoding_disabled_description": "Älä muunna videoita. Voi joissakin päätelaitteissa aiheuttaa videotoiston toimimattomuutta", "transcoding_hardware_acceleration": "Laitteistokiihdytys", @@ -283,7 +288,7 @@ "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", - "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin `faster`.", + "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin 'faster'.", "transcoding_reference_frames": "Kehysviitteet", "transcoding_reference_frames_description": "Viittaavien kehysten määrä kun tiettyä kehystä pakataan. Korkeampi arvo parantaa pakkausta mutta hidastaa enkoodausta. 0 määrittää arvon automaattisesti.", "transcoding_required_description": "Vain videoille, jotka eivät ole hyväksytyssä muodossa", @@ -302,7 +307,7 @@ "transcoding_transcode_policy": "Transkoodauskäytäntö", "transcoding_transcode_policy_description": "Käytäntö miten video tulisi transkoodata. HDR videot transkoodataan aina, paitsi jos transkoodaus on poistettu käytöstä.", "transcoding_two_pass_encoding": "Two-pass enkoodaus", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_two_pass_encoding_setting_description": "Transkoodaa kahdessa vaiheessa tuottaaksesi paremmin koodattuja videoita. Kun maksimibittinopeus on käytössä (vaaditaan H.264- ja HEVC-koodaukselle), tämä tila käyttää bittinopeusaluetta, joka perustuu maksimibittinopeuteen ja ohittaa CRF. VP9 osalta CRF:ää voidaan käyttää, jos maksimibittinopeus on poistettu käytöstä.", "transcoding_video_codec": "Videokoodekki", "transcoding_video_codec_description": "VP9 on tehokkain ja web-yhteensopiva, mutta muuntaminen kestää kauemmin. HEVC suoriutuu yhtäläisesti, mutta ei ole ihan yhtä yhteensopiva. H.264 on hyvin yhteensopiva ja nopea muuntaa, mutta tuottaa paljon suurempia tiedostoja. AV1 on kaikkein tehokkain koodekki, mutta vanhemmat laitteet eivät sitä tue.", "trash_enabled_description": "Ota käyttöön roskakori", @@ -312,15 +317,22 @@ "trash_settings_description": "Hallitse roskakoriasetuksia", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_description": "Nämä tiedostot eivät ole ohjelman hallitsemia. Ne voivat olla virheellisten siirtojen tai keskeytyneiden latausten tulosta, tai bugista johtuvia jälkeen jääneitä", + "user_cleanup_job": "Käyttäjien puhdistus", + "user_delete_delay": "Käyttäjän {user} tili ja aineistot aikataulutetaan poistettavaksi ajan kuluttua: {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Poiston viive", "user_delete_delay_settings_description": "Montako päivää poistamisen jälkeen käyttäjä ja hänen aineistonsa poistetaan pysyvästi. Joka keskiyö käydään läpi poistetuiksi merkityt käyttäjät. Tämä muutos astuu voimaan seuraavalla ajokerralla.", + "user_delete_immediately": "{user}:n tili ja sen kohteet on ajastettu poistettavaksi heti.", + "user_delete_immediately_checkbox": "Aseta tili ja sen kohteet jonoon välitöntä poistoa varten", "user_management": "Käyttäjien hallinta", "user_password_has_been_reset": "Käyttäjän salasana on nollattu:", "user_password_reset_description": "Anna väliaikainen salasana ja ohjeista käyttäjää vaihtamaan se seuraavan kirjautumisen yhteydessä.", + "user_restore_description": "{user}:n tili palautetaan.", + "user_restore_scheduled_removal": "Palauta käyttäjä - Aikataulutettu poisto tapahtuu {date, date, long}", "user_settings": "Käyttäjäasetukset", "user_settings_description": "Hallitse käyttäjäasetuksia", "user_successfully_removed": "Käyttäjä {email} on poistettu.", - "version_check_enabled_description": "Ota käyttöön säännölliset uusien versioiden tarkistukset GitHubista", + "version_check_enabled_description": "Ota käyttöön versiotarkastus", + "version_check_implications": "Versiontarkistus vaatii säännöllisen yhteyden github.com:iin", "version_check_settings": "Versiotarkistus", "version_check_settings_description": "Ota käyttöön ilmoitukset, kun uusi versio on saatavilla", "video_conversion_job": "Transkoodaa videot", @@ -336,17 +348,21 @@ "album_added": "Albumi lisätty", "album_added_notification_setting_description": "Saa sähköpostia kun sinut lisätään jaettuun albumiin", "album_cover_updated": "Albumin kansikuva päivitetty", - "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", + "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?", + "album_delete_confirmation_description": "Jos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", "album_leave": "Poistu albumista?", + "album_leave_confirmation": "Haluatko varmasti poistua albumista {album}?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", "album_remove_user": "Poista käyttäjä?", - "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", + "album_user_left": "Poistuttiin albumista {album}", "album_user_removed": "{user} poistettu", + "album_with_link_access": "Anna kenen tahansa nähdä linkin kautta tämän albumin valokuvat ja henkilöt.", "albums": "Albumit", "albums_count": "{count, plural, one {{count, number} albumi} other {{count, number} albumia}}", "all": "Kaikki", @@ -355,7 +371,12 @@ "all_videos": "Kaikki videot", "allow_dark_mode": "Salli tumma tila", "allow_edits": "Salli muutokset", + "allow_public_user_to_download": "Salli julkisten käyttäjien ladata tiedostoja", + "allow_public_user_to_upload": "Salli julkisten käyttäjien lähettää tiedostoja", + "anti_clockwise": "Vastapäivään", "api_key": "API-avain", + "api_key_description": "Tämä arvo näytetään vain kerran. Varmista, että olet kopioinut sen ennen kuin suljet ikkunan.", + "api_key_empty": "API-avaimesi ei pitäisi olla tyhjä", "api_keys": "API-avaimet", "app_settings": "Sovellusasetukset", "appears_in": "Esiintyy albumeissa", @@ -369,14 +390,20 @@ "are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?", "asset_added_to_album": "Lisätty albumiin", "asset_adding_to_album": "Lisätään albumiin...", + "asset_description_updated": "Kohteen kuvaus on päivitetty", + "asset_filename_is_offline": "Kohde {filename} on offline-tilassa", + "asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja", + "asset_hashing": "Hajautetaan...", "asset_offline": "Aineisto offline-tilassa", + "asset_offline_description": "Tämä kohde on offline-tilassa. Immich ei pääse tiedoston sijaintiin. Varmista, että kohde on saatavilla, ja skannaa sitten kirjasto uudelleen.", "asset_skipped": "Ohitettu", + "asset_skipped_in_trash": "Roskakorissa", "asset_uploaded": "Lähetetty", "asset_uploading": "Lähetetään…", "assets": "kohdetta", "assets_added_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}}", "assets_added_to_album_count": "Albumiin lisätty {count, plural, one {# kohde} other {# kohdetta}}", - "assets_added_to_name_count": "{name}:n lisätty {count, plural, one {# media} other {# mediaa}}", + "assets_added_to_name_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}} {hasName, select, true {{name}} other {uuteen albumiin}}", "assets_count": "{count, plural, one {# media} other {# mediaa}}", "assets_moved_to_trash": "Siirretty {count, plural, one {# aineisto} other {# aineistoa}} roskakoriin", "assets_moved_to_trash_count": "Siirretty {count, plural, one {# media} other {# mediaa}} roskakoriin", @@ -398,6 +425,7 @@ "bulk_delete_duplicates_confirmation": "Haluatko varmasti poistaa {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} kerralla? Tämä säilyttää kustakin mediasta kookkaimman ja poistaa loput pysyvästi. Et voi perua tätä!", "bulk_keep_duplicates_confirmation": "Haluatko varmasti säilyttää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}}? Tämä merkitsee kaikki kaksoiskappaleet ratkaistuiksi, eikä poista mitään.", "bulk_trash_duplicates_confirmation": "Haluatko varmasti siirtää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} roskakoriin? Tämä säilyttää kustakin mediasta kookkaimman ja siirtää loput roskakoriin.", + "buy": "Osta lisenssi Immich:iin", "camera": "Kamera", "camera_brand": "Kameran merkki", "camera_model": "Kameran malli", @@ -415,7 +443,7 @@ "change_location": "Vaihda sijainti", "change_name": "Vaihda nimi", "change_name_successfully": "Nimi vaihdettu", - "change_password": "Vaihda salasana", + "change_password": "Vaihda Salasana", "change_password_description": "Tämä on joko ensimmäinen kertasi kun kirjaudut järjestelmään, tai salasanasi on pyydetty vaihtamaan. Määritä uusi salasana alle.", "change_your_password": "Vaihda salasanasi", "changed_visibility_successfully": "Näkyvyys vaihdettu", @@ -425,11 +453,14 @@ "city": "Kaupunki", "clear": "Tyhjennä", "clear_all": "Tyhjennä kaikki", + "clear_all_recent_searches": "Tyhjennä viimeisimmät haut", "clear_message": "Tyhjennä viesti", "clear_value": "Tyhjää arvo", + "clockwise": "Myötäpäivään", "close": "Sulje", "collapse": "Supista", "collapse_all": "Sulje kaikki", + "color": "Väri", "color_theme": "Väriteema", "comment_deleted": "Kommentti poistettu", "comment_options": "Kommentin valinnat", @@ -463,13 +494,15 @@ "create_new_person": "Luo uusi henkilö", "create_new_person_hint": "Määritä valitut mediat uudelle henkilölle", "create_new_user": "Luo uusi käyttäjä", + "create_tag": "Luo tunniste", + "create_tag_description": "Luo uusi tunniste. Sisäkkäisiä tunnisteita varten, syötä tunnisteen täydellinen polku kauttaviiva mukaanluettuna.", "create_user": "Luo käyttäjä", "created": "Luotu", "current_device": "Nykyinen laite", "custom_locale": "Muokatut maa-asetukset", "custom_locale_description": "Muotoile päivämäärät ja numerot perustuen alueen kieleen", "dark": "Tumma", - "date_after": "Päivä jälkeen", + "date_after": "Päivämäärän jälkeen", "date_and_time": "Päivämäärä ja aika", "date_before": "Päivä ennen", "date_of_birth_saved": "Syntymäaika tallennettu", @@ -486,6 +519,8 @@ "delete_library": "Poista kirjasto", "delete_link": "Poista linkki", "delete_shared_link": "Poista jaettu linkki", + "delete_tag": "Poista tunniste", + "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa {tagName}-tunnisteen?", "delete_user": "Poista käyttäjä", "deleted_shared_link": "Jaettu linkki poistettu", "description": "Kuvaus", @@ -503,6 +538,8 @@ "do_not_show_again": "Älä näytä tätä enää", "done": "Valmis", "download": "Lataa", + "download_include_embedded_motion_videos": "Upotetut videot", + "download_include_embedded_motion_videos_description": "Sisällytä liikekuviin upotetut videot erillisinä tiedostoina", "download_settings": "Lataukset", "download_settings_description": "Hallitse aineiston lataukseen liittyviä asetuksia", "downloading": "Ladataan", @@ -532,10 +569,15 @@ "edit_location": "Muokkaa sijaintia", "edit_name": "Muokkaa nimeä", "edit_people": "Muokkaa henkilöitä", + "edit_tag": "Muokkaa tunnistetta", "edit_title": "Muokkaa otsikkoa", "edit_user": "Muokkaa käyttäjää", "edited": "Muokattu", - "editor": "", + "editor": "Editori", + "editor_close_without_save_prompt": "Muutoksia ei tallenneta", + "editor_close_without_save_title": "Suljetaanko editori?", + "editor_crop_tool_h2_aspect_ratios": "Kuvasuhteet", + "editor_crop_tool_h2_rotation": "Rotaatio", "email": "Sähköposti", "empty": "", "empty_album": "", @@ -563,6 +605,7 @@ "error_adding_users_to_album": "Käyttäjiä ei voitu lisätä albumiin", "error_deleting_shared_user": "Jaettua käyttäjää ei voitu poistaa", "error_downloading": "Tiedostoa {filename} ei voitu ladata", + "error_hiding_buy_button": "Virhe osta-painikkeen piilottamisessa", "error_removing_assets_from_album": "Medioiden poisto epäonnistui. Katso konsolista lisätietoja", "error_selecting_all_assets": "Kaikkia medioita ei voitu valita", "exclusion_pattern_already_exists": "Tämä poissulkemismalli on jo olemassa.", @@ -573,6 +616,8 @@ "failed_to_get_people": "Henkilöiden haku epäonnistui", "failed_to_load_asset": "Kohteen lataus epäonnistui", "failed_to_load_assets": "Kohteiden lataus epäonnistui", + "failed_to_load_people": "Henkilöiden lataus epäonnistui", + "failed_to_remove_product_key": "Tuoteavaimen poistaminen epäonnistui", "failed_to_stack_assets": "Medioiden pinoaminen epäonnistui", "failed_to_unstack_assets": "Medioiden pinoamisen purku epäonnistui", "import_path_already_exists": "Tämä tuontipolku on jo olemassa.", @@ -580,54 +625,90 @@ "paths_validation_failed": "{paths, plural, one {# polun} other {# polun}} validointi epäonnistui", "profile_picture_transparent_pixels": "Profiilikuvassa ei voi olla läpinäkyviä pikseleitä. Zoomaa lähemmäs ja/tai siirrä kuvaa.", "quota_higher_than_disk_size": "Asettamasi kiintiö on suurempi kuin levyn koko", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", + "repair_unable_to_check_items": "Ei voida tarkistaa {count, select, one {kohdetta} other {kohteita}}", + "unable_to_add_album_users": "Käyttäjiä ei voi lisätä albumiin", + "unable_to_add_assets_to_shared_link": "Medioiden lisääminen jaettuun linkkiin epäonnistui", + "unable_to_add_comment": "Kommentin lisääminen epäonnistui", + "unable_to_add_exclusion_pattern": "Ei voida lisätä poissulkuohjetta", + "unable_to_add_import_path": "Tuontipolkua ei voitu lisätä", + "unable_to_add_partners": "Kumppaneita ei voitu lisätä", + "unable_to_add_remove_archive": "Ei voida {archived, select, true {poistaa kohdetta arkistosta} other {lisätä kohdetta arkistoon}}", + "unable_to_add_remove_favorites": "Ei voida {favorite, select, true {lisätä kohdetta suosikkeihin} other {poistaa kohdetta suosikeista}}", + "unable_to_archive_unarchive": "Ei voida {archived, select, true {arkistoida} other {poistaa arkistosta}}", + "unable_to_change_album_user_role": "Albumin käyttäjän roolia ei voitu muuttaa", + "unable_to_change_date": "Päivämäärää ei voitu muuttaa", + "unable_to_change_favorite": "Ei voida muuttaa suosikkia kohteelle", "unable_to_change_location": "Sijainnin muuttaminen epäonnistui", "unable_to_change_password": "Salasanan vaihto epäonnistui", + "unable_to_change_visibility": "Ei voida muuttaa näkyvyyttä {count, plural, one {# henkilölle} other {# henkilölle}}", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth-kirjautumista ei voitu suorittaa loppuun", + "unable_to_connect": "Yhteyttä ei voitu muodostaa", "unable_to_connect_to_server": "Palvelimeen ei saatu yhteyttä", + "unable_to_copy_to_clipboard": "Leikepöydälle ei voitu kopioida, varmista että käytät sivua https-yhteyden kautta", "unable_to_create_admin_account": "Pääkäyttäjän luominen epäonnistui", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_create_api_key": "Uuden API-avaimen luominen epäonnistui", + "unable_to_create_library": "Kirjaston luominen epäonnistui", + "unable_to_create_user": "Käyttäjän luominen epäonnistui", + "unable_to_delete_album": "Albumin poistaminen epäonnistui", + "unable_to_delete_asset": "Kohteen poistaminen epäonnistui", + "unable_to_delete_assets": "Virhe kohteen poistamisessa", + "unable_to_delete_exclusion_pattern": "Ei voida poistaa poissulkuohjetta", + "unable_to_delete_import_path": "Tuontipolkua ei voitu poistaa", + "unable_to_delete_shared_link": "Jaetun linkin poistaminen epäonnistui", + "unable_to_delete_user": "Käyttäjän poistaminen epäonnistui", + "unable_to_download_files": "Tiedostojen lataaminen epäonnistui", + "unable_to_edit_exclusion_pattern": "Ei voida muokata poissulkuohjetta", + "unable_to_edit_import_path": "Tuontipolkua ei voitu muokata", + "unable_to_empty_trash": "Roskakorin tyhjentäminen epäonnistui", + "unable_to_enter_fullscreen": "Koko ruudun tilaan siirtyminen epäonnistui", + "unable_to_exit_fullscreen": "Koko ruudun tilasta poistuminen epäonnistui", + "unable_to_get_comments_number": "Kommenttien määrän hakeminen epäonnistui", + "unable_to_get_shared_link": "Jaetun linkin hakeminen epäonnistui", + "unable_to_hide_person": "Henkilön piilottaminen epäonnistui", + "unable_to_link_motion_video": "Liikekuvan linkitys epäonnistui", + "unable_to_link_oauth_account": "OAuth-tilin linkittäminen epäonnistui", + "unable_to_load_album": "Albumin lataaminen epäonnistui", + "unable_to_load_asset_activity": "Ei voitu ladata kohteen toimintaa", + "unable_to_load_items": "Kohteiden lataaminen epäonnistui", + "unable_to_load_liked_status": "Ei voitu ladata tykkäyksen tilaa", + "unable_to_log_out_all_devices": "Kaikkien laitteiden uloskirjautuminen epäonnistui", + "unable_to_log_out_device": "Laitteen uloskirjautuminen epäonnistui", + "unable_to_login_with_oauth": "OAuth-kirjautuminen epäonnistui", + "unable_to_play_video": "Videon toistaminen epäonnistui", + "unable_to_reassign_assets_existing_person": "Ei voida siirtää kohteita {name, select, null {olemassa olevalle henkilölle} other {{name}}}", + "unable_to_reassign_assets_new_person": "Ei voida siirtää kohteita uudelle henkilölle", + "unable_to_refresh_user": "Käyttäjän päivittäminen epäonnistui", + "unable_to_remove_album_users": "Käyttäjien poistaminen albumista epäonnistui", + "unable_to_remove_api_key": "API-avaimen poistaminen epäonnistui", + "unable_to_remove_assets_from_shared_link": "kohteiden poistaminen jaetusta linkistä epäonnistui", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Kirjaston poistaminen epäonnistui", + "unable_to_remove_offline_files": "Offline-tiedostojen poistaminen epäonnistui", + "unable_to_remove_partner": "Kumppanin poistaminen epäonnistui", + "unable_to_remove_reaction": "Reaktion poistaminen epäonnistui", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", + "unable_to_repair_items": "Kohteiden korjaaminen epäonnistui", + "unable_to_reset_password": "Salasanan nollaaminen epäonnistui", + "unable_to_resolve_duplicate": "Virheilmoitus näkyy, kun palvelin palauttaa virheen painettaessa roskakorin tai säilytä-painiketta.", + "unable_to_restore_assets": "Kohteen palauttaminen epäonnistui", + "unable_to_restore_trash": "Kohteiden palauttaminen epäonnistui", + "unable_to_restore_user": "Käyttäjän palauttaminen epäonnistui", + "unable_to_save_album": "Albumin tallentaminen epäonnistui", + "unable_to_save_api_key": "API-avaimen tallentaminen epäonnistui", + "unable_to_save_date_of_birth": "Syntymäajan tallentaminen epäonnistui", + "unable_to_save_name": "Nimen tallentaminen epäonnistui", + "unable_to_save_profile": "Profiilin tallentaminen epäonnistui", + "unable_to_save_settings": "Asetusten tallentaminen epäonnistui", + "unable_to_scan_libraries": "Kirjastojen skannaaminen epäonnistui", + "unable_to_scan_library": "Kirjaston skannaaminen epäonnistui", + "unable_to_set_feature_photo": "Ei voida asettaa ominaiskuvaa", "unable_to_set_profile_picture": "Profiilikuvan asetus epäonnistui", "unable_to_submit_job": "Työtä ei voitu lähettää", "unable_to_trash_asset": "Median siirto roskakoriin epäonnistui", "unable_to_unlink_account": "Tunnuksen irroitus epäonnistui", + "unable_to_unlink_motion_video": "Ei voida irrottaa liikevideota", "unable_to_update_album_cover": "Albumin kannen päivitys epäonnistui", "unable_to_update_album_info": "Albumin tietojen päivitys epäonnistui", "unable_to_update_library": "Kirjaston päivitys epäonnistui", @@ -648,59 +729,82 @@ "expired": "Voimassaolo päättynyt", "expires_date": "Vanhenee {date}", "explore": "Tutki", + "explorer": "Tutkija", "export": "Vie", "export_as_json": "Vie JSON-muodossa", - "extension": "", - "external_libraries": "", + "extension": "Tiedostopääte", + "external": "Ulkoisesta", + "external_libraries": "Ulkoiset kirjastot", + "face_unassigned": "Ei määritelty", "failed_to_get_people": "", "favorite": "Suosikki", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "Suosikki- tai ei-suosikkikuva", "favorites": "Suosikit", "feature": "", "feature_photo_updated": "Kansikuva ladattu", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "features": "Ominaisuudet", + "features_setting_description": "Hallitse sovelluksen ominaisuuksia", + "file_name": "Tiedoston nimi", + "file_name_or_extension": "Tiedostonimi tai tiedostopääte", "filename": "Tiedostonimi", "files": "", "filetype": "Tiedostotyyppi", - "filter_people": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", + "filter_people": "Suodata henkilöt", + "find_them_fast": "Löydä nopeasti hakemalla nimellä", + "fix_incorrect_match": "Korjaa virheellinen osuma", + "folders": "Kansiot", + "folders_feature_description": "Käytetään kansionäkymää valokuvien ja videoiden selaamiseen järjestelmässä", + "force_re-scan_library_files": "Pakota kaikkien kirjastotiedostojen uudelleenskannaus", "forward": "Eteenpäin", - "general": "", - "get_help": "", - "getting_started": "", + "general": "Yleinen", + "get_help": "Hae apua", + "getting_started": "Aloittaminen", "go_back": "Palaa", - "go_to_search": "", + "go_to_search": "Siirry hakuun", "go_to_share_page": "", - "group_albums_by": "", + "group_albums_by": "Ryhmitä albumi...", "group_no": "Ei ryhmitystä", "group_owner": "Ryhmitä omistajan mukaan", "group_year": "Ryhmitä vuoden mukaan", - "has_quota": "", + "has_quota": "On kiintiö", "hi_user": "Hei {name} ({email})", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", + "hide_all_people": "Piilota kaikki henkilöt", + "hide_gallery": "Piilota galleria", + "hide_named_person": "Piilota henkilön {name}", + "hide_password": "Piilota salasana", + "hide_person": "Piilota henkilö", + "hide_unnamed_people": "Piilota nimeämättömät henkilöt", + "host": "Isäntä", "hour": "Tunti", "image": "Kuva", + "image_alt_text_date": "{isVideo, select, true {Video} other {Kuva}} otettu {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {person1} kanssa {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {additionalCount, number} muissa kanssa {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n kanssa {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {additionalCount, number} muun kanssa {date}", "img": "", - "immich_logo": "", - "import_path": "", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich verkkoliittymä", + "import_from_json": "Tuo JSON-tiedostosta", + "import_path": "Tuontipolku", "in_albums": "{count, plural, one {# Albumissa} other {# albumissa}}", "in_archive": "Arkistossa", "include_archived": "Sisällytä arkistoidut", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", + "include_shared_albums": "Sisällytä jaetut albumit", + "include_shared_partner_assets": "Sisällytä jaetut kumppanikohteet", + "individual_share": "Yksittäinen jako", "info": "Lisätietoja", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Joka päivä klo 13:00", + "hours": "Joka {hours, plural, one {tunti} other {{hours, number} tuntia}}", + "night_at_midnight": "Joka yö keskiyöllä", + "night_at_twoam": "Joka yö klo 02:00" }, "invite_people": "Kutsu ihmisiä", "invite_to_album": "Kutsu albumiin", @@ -714,47 +818,58 @@ "language_setting_description": "Valitse suosimasi kieli", "last_seen": "Viimeksi nähty", "latest_version": "Viimeisin versio", + "latitude": "Leveysaste", "leave": "Lähde", "let_others_respond": "Anna muiden vastata", "level": "Taso", "library": "Kirjasto", - "library_options": "", + "library_options": "Kirjastovaihtoehdot", "license_button_buy": "Osta", "license_button_select": "Valitse", "light": "Vaalea", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", + "like_deleted": "Tykkäys poistettu", + "link_motion_video": "Linkitä liikevideo", + "link_options": "Linkin asetukset", + "link_to_oauth": "Linkki OAuth", + "linked_oauth_account": "Linkitetty OAuth-tili", "list": "Lista", "loading": "Ladataan", - "loading_search_results_failed": "", + "loading_search_results_failed": "Hakutulosten lataaminen epäonnistui", "log_out": "Kirjaudu ulos", "log_out_all_devices": "Kirjaudu ulos kaikilta laitteilta", + "logged_out_all_devices": "Kaikki laitteet kirjattu ulos", + "logged_out_device": "Laite kirjattu ulos", "login": "Kirjaudu", "login_has_been_disabled": "Kirjautuminen on otettu pois käytöstä.", "logout_all_device_confirmation": "Haluatko varmasti kirjautua ulos kaikilta laitteilta?", "logout_this_device_confirmation": "Haluatko varmasti kirjautua ulos näiltä laitteilta?", + "longitude": "Pituusaste", "look": "Tyyli", - "loop_videos": "", - "loop_videos_description": "", + "loop_videos": "Toista videot uudelleen", + "loop_videos_description": "Ota käyttöön videon automaattinen toisto tarkemmassa näkymässä.", "make": "Valmistaja", "manage_shared_links": "Hallitse jaettuja linkkejä", - "manage_sharing_with_partners": "", + "manage_sharing_with_partners": "Hallitse jakamista kumppaneille", "manage_the_app_settings": "Hallitse sovelluksen asetuksia", "manage_your_account": "Hallitse tiliäsi", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", + "manage_your_api_keys": "Hallitse API-avaimiasi", + "manage_your_devices": "Hallitse sisäänkirjautuneita laitteitasi", + "manage_your_oauth_connection": "Hallitse OAuth-yhteyttäsi", "map": "Kartta", - "map_marker_with_image": "", + "map_marker_for_images": "Karttamarkerointi kuville, jotka on otettu {city}ssä, {country}ssä", + "map_marker_with_image": "Karttamarkerointi kuvalla", "map_settings": "Kartta-asetukset", + "matches": "Osumia", "media_type": "Median tyyppi", - "memories": "", - "memories_setting_description": "", + "memories": "Muistoja", + "memories_setting_description": "Hallitse mitä näet muistoissasi", "memory": "Muisto", + "memory_lane_title": "Muistojen polku {title}", "menu": "Valikko", "merge": "Yhdistä", "merge_people": "Yhdistä henkilöt", + "merge_people_limit": "Voit yhdistää vain enintään 5 kasvoa kerrallaan", + "merge_people_prompt": "Haluatko yhdistää nämä henkilöt? Tätä valintaa ei voi peruuttaa.", "merge_people_successfully": "Henkilöt yhdistetty", "merged_people_count": "{count, plural, one {# Henkilö} other {# henkilöä}} yhdistetty", "minimize": "PIenennä", @@ -768,6 +883,7 @@ "name": "Nimi", "name_or_nickname": "Nimi tai lempinimi", "never": "ei koskaan", + "new_album": "Uusi Albumi", "new_api_key": "Uusi API Key", "new_password": "Uusi salasana", "new_person": "Uusi henkilö", @@ -780,42 +896,55 @@ "no_albums_message": "Luo albumi pitääksesi kuvat ja videot järjestyksessä", "no_albums_with_name_yet": "Näyttää siltä, ettei sinulla ole yhtään tämän nimistä albumia.", "no_albums_yet": "Näyttää siltä, ettei sinulla ole vielä yhtään albumia.", - "no_archived_assets_message": "", + "no_archived_assets_message": "Arkistoi kuvia ja videoita piilottaaksesi ne kuvat näkymästä", "no_assets_message": "NAPAUTA LATAAKSESI ENSIMMÄISEN KUVASI", + "no_duplicates_found": "Kaksoiskappaleita ei löytynyt.", "no_exif_info_available": "EXIF-tietoa ei saatavilla", - "no_explore_results_message": "", + "no_explore_results_message": "Lataa lisää kuvia tutkiaksesi kokoelmaasi.", "no_favorites_message": "Lisää suosikkeja löytääksesi nopeasti parhaat kuvasi ja videosi", - "no_libraries_message": "", + "no_libraries_message": "Luo ulkoinen kirjasto nähdäksesi valokuvasi ja videot", "no_name": "Ei nimeä", - "no_places": "", + "no_places": "Ei paikkoja", "no_results": "Ei tuloksia", + "no_results_description": "Kokeile synonyymiä tai yleisempää avainsanaa", "no_shared_albums_message": "Luo albumi, jotta voit jakaa kuvia ja videoita toisille", "not_in_any_album": "Ei yhdessäkään albumissa", + "note_apply_storage_label_to_previously_uploaded assets": "Huom: Jotta voit soveltaa tallennustunnistetta aiemmin ladattuihin kohteisiin, suorita", + "note_unlimited_quota": "Huomio: Syötä 0 rajoittamatonta kiintiötä varten", "notes": "Muistiinpanot", "notification_toggle_setting_description": "Ota sähköpostilmoitukset käyttöön", "notifications": "Ilmoitukset", "notifications_setting_description": "Hallitse ilmoituksia", "oauth": "OAuth", - "offline": "", + "offline": "Offline", + "offline_paths": "Offline-polut", + "offline_paths_description": "Nämä tulokset voivat johtua tiedostojen manuaalisesta poistamisesta, jotka eivät ole osa ulkoista kirjastoa.", "ok": "Ok", "oldest_first": "Vanhin ensin", + "onboarding": "Käyttöönotto", + "onboarding_privacy_description": "Seuraavat (valinnaiset) ominaisuudet perustuvat ulkoisiin palveluihin, ja ne voidaan poistaa käytöstä milloin tahansa hallinta asetuksista.", + "onboarding_theme_description": "Valitse väriteema istunnollesi. Voit muuttaa tämän myöhemmin asetuksistasi.", + "onboarding_welcome_description": "Aloitetaa laittamalla istuntoosi joitakin yleisiä asetuksia.", "onboarding_welcome_user": "Tervetuloa {user}", "online": "Online", "only_favorites": "Vain suosikit", - "only_refreshes_modified_files": "", + "only_refreshes_modified_files": "Päivittää vain muakatut tiedostot", + "open_in_map_view": "Avaa karttanäkymässä", "open_in_openstreetmap": "Avaa OpenStreetMapissa", - "open_the_search_filters": "", + "open_the_search_filters": "Avaa hakusuodattimet", "options": "Vaihtoehdot", "or": "tai", "organize_your_library": "Järjestele kirjastosi", "original": "alkuperäinen", "other": "Muut", "other_devices": "Toiset laitteet", - "other_variables": "", + "other_variables": "Muut muuttujat", "owned": "Omistettu", "owner": "Omistaja", "partner": "Kumppani", "partner_can_access": "{partner} voi päästä", + "partner_can_access_assets": "Kaikki valokuvasi ja videosi, lukuun ottamatta arkistoituja ja poistettuja", + "partner_can_access_location": "Sijainti, jossa kuvasi on otettu", "partner_sharing": "Kumppanijako", "partners": "Kumppanit", "password": "Salasana", @@ -823,22 +952,26 @@ "password_required": "Salasana vaaditaan", "password_reset_success": "Salasanan nollaus onnistui", "past_durations": { - "days": "{years, plural, one {Viimeisin päivä} other {Viimeiset # päivää}}", - "hours": "{years, plural, one {Viimeisin tunti} other {Viimeiset # tuntia}}", + "days": "Viime {days, plural, one {päivä} other {# päivää}}", + "hours": "Viime {hours, plural, one {tunti} other {# tuntia}}", "years": "{years, plural, one {Viimeisin vuosi} other {Viimeiset # vuotta}}" }, "path": "Polku", - "pattern": "", + "pattern": "Kaava", "pause": "Tauko", - "pause_memories": "", + "pause_memories": "Pysäytä muistot", "paused": "Tauotettu", "pending": "Odottaa", "people": "Ihmiset", - "people_sidebar_description": "", + "people_edits_count": "Muokattu {count, plural, one {# henkilö} other {# henkilöä}}", + "people_feature_description": "Selataan valokuvia ja videoita, jotka on ryhmitelty henkilöiden mukaan", + "people_sidebar_description": "Näytä linkki Henkilöihin sivupalkissa", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Pysyvän poiston varoitus", + "permanent_deletion_warning_setting_description": "Näytä varoitus, kun poistat kohteita pysyvästi", "permanently_delete": "Poista pysyvästi", + "permanently_delete_assets_count": "Poista pysyvästi {count, plural, one {kohde} other {kohteita}}", + "permanently_delete_assets_prompt": "Oletko varma, että haluat poistaa pysyvästi {count, plural, one {tämän kohteen?} other {nämä # kohteet?}} Tämä poistaa myös {count, plural, one {sen sen} other {ne niiden}} albumista.", "permanently_deleted_asset": "Media poistettu pysyvästi", "permanently_deleted_assets_count": "{count, plural, one {# media} other {# mediaa}} poistettu pysyvästi", "person": "Henkilö", @@ -853,7 +986,7 @@ "places": "Paikat", "play": "Toista", "play_memories": "Toista muistot", - "play_motion_photo": "", + "play_motion_photo": "Toista Liikekuva", "play_or_pause_video": "Toista tai keskeytä video", "point": "", "port": "Portti", @@ -863,15 +996,53 @@ "previous_memory": "Edellinen muisto", "previous_or_next_photo": "Edellinen tai seuraava kuva", "primary": "Ensisijainen", + "privacy": "Yksityisyys", "profile_image_of_user": "Käyttäjän {user} profiilikuva", "profile_picture_set": "Profiilikuva asetettu.", "public_album": "Julkinen albumi", "public_share": "Julkinen jako", + "purchase_account_info": "Tukija", + "purchase_activated_subtitle": "Kiitos Immichin ja avoimen lähdekoodin ohjelmiston tukemisesta", + "purchase_activated_time": "Aktivoitu {date, date}", + "purchase_activated_title": "Avaimesi on aktivoitu onnistuneesti", + "purchase_button_activate": "Aktivoi", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Älä näytä koskaan uudelleen", + "purchase_button_reminder": "Muistuta minua 30 päivän kuluessa", + "purchase_button_remove_key": "Poista avain", + "purchase_button_select": "Valitse", + "purchase_failed_activation": "Aktivointi epäonnistui! Tarkista sähköpostisi oikean tuoteavaimen varalta!", + "purchase_individual_description_1": "Yksittäiselle henkilölle", + "purchase_individual_description_2": "Tukijan tila", + "purchase_individual_title": "Yksittäinen", + "purchase_input_suggestion": "Onko sinulla tuoteavain? Syötä avain alle", + "purchase_license_subtitle": "Osta Immich tukeaksesi palvelun jatkuvaa kehittämistä", + "purchase_lifetime_description": "Elinikäinen osto", + "purchase_option_title": "OSTOVAIHTOEHDOT", + "purchase_panel_info_1": "Immichin rakentaminen vie paljon aikaa ja vaivannäköä, ja meillä on kokopäiväisiä insinöörejä työskentelemässä sen parissa, jotta voimme tehdä siitä mahdollisimman hyvän. Missiomme on, että avoimen lähdekoodin ohjelmistosta ja eettisistä liiketoimintakäytännöistä tulee kestävä tulonlähde kehittäjille, sekä luoda yksityisyyttä kunnioittava ekosysteemi, jossa on todellisia vaihtoehtoja hyväksikäyttöön perustuville pilvipalveluille.", + "purchase_panel_info_2": "Koska olemme sitoutuneet siihen, ettemme lisää maksumuuria, tämä osto ei anna sinulle mitään lisäominaisuuksia Immichissa. Luotamme kaltaisiisi käyttäjiin tukeaksemme Immichin jatkuvaa kehittämistä.", + "purchase_panel_title": "Tue projektia", + "purchase_per_server": "Per serveri", + "purchase_per_user": "Per käyttäjä", + "purchase_remove_product_key": "Poista Tuoteavain", + "purchase_remove_product_key_prompt": "Haluatko varmasti poistaa tuoteavaimen?", + "purchase_remove_server_product_key": "Poista palvelimen tuoteavain", + "purchase_remove_server_product_key_prompt": "Haluatko varmasti poistaa palvelimen tuoteavaimen?", + "purchase_server_description_1": "Koko palvelimelle", + "purchase_server_description_2": "Tukijan tila", + "purchase_server_title": "Serveri", + "purchase_settings_server_activated": "Palvelimen tuoteavainta hallinnoi ylläpitäjä", "range": "", + "rating": "Tähtiarvostelu", + "rating_clear": "Tyhjennä arvostelu", + "rating_count": "{count, plural, one {# tähti} other {# tähteä}}", + "rating_description": "Näytä EXIF-arvosana tiedot-paneelissa", "raw": "", - "reaction_options": "", + "reaction_options": "Reaktioasetukset", "read_changelog": "Lue muutosloki", "reassign": "Määritä uudelleen", + "reassigned_assets_to_existing_person": "Uudelleen määritetty {count, plural, one {# kohde} other {# kohdetta}} {name, select, null {olemassa olevalle henkilölle} other {{name}}}", "reassigned_assets_to_new_person": "Määritetty {count, plural, one {# media} other {# mediaa}} uudelle henkilölle", "reassing_hint": "Määritä valitut mediat käyttäjälle", "recent": "Viimeisin", @@ -899,9 +1070,10 @@ "removed_from_archive": "Poistettu arkistosta", "removed_from_favorites": "Poistettu suosikeista", "removed_from_favorites_count": "{count, plural, other {Poistettu #}} suosikeista", + "removed_tagged_assets": "Poistettu tunniste {count, plural, one {# kohteesta} other {# kohteesta}}", "rename": "Nimeä uudelleen", "repair": "Korjaa", - "repair_no_results_message": "", + "repair_no_results_message": "Seuraamattomat ja puuttuvat tiedostot näkyvät täällä", "replace_with_upload": "Korvaa tiedostolla", "repository": "Tietovarasto", "require_password": "Vaadi salasana", @@ -911,6 +1083,7 @@ "reset_people_visibility": "Nollaa henkilöiden näkyvyysasetukset", "reset_settings_to_default": "", "reset_to_default": "Palauta oletusasetukset", + "resolve_duplicates": "Ratkaise kaksoiskappaleet", "resolved_all_duplicates": "Kaikki kaksoiskappaleet selvitetty", "restore": "Palauta", "restore_all": "Palauta kaikki", @@ -920,7 +1093,7 @@ "retry_upload": "Yritä latausta uudelleen", "review_duplicates": "Tarkastele kaksoiskappaleita", "role": "Rooli", - "role_editor": "Muokkain", + "role_editor": "Editori", "role_viewer": "Toistin", "save": "Tallenna", "saved_api_key": "API Key tallennettu", @@ -935,6 +1108,8 @@ "search": "Haku", "search_albums": "Etsi albumeita", "search_by_context": "Etsi kontekstin perusteella", + "search_by_filename": "Hae tiedostonimen tai -päätteen mukaan", + "search_by_filename_example": "esim. IMG_1234.JPG tai PNG", "search_camera_make": "Etsi kameramerkkiä...", "search_camera_model": "Etsi kameramallia...", "search_city": "Etsi kaupunkia...", @@ -942,9 +1117,12 @@ "search_for_existing_person": "Etsi olemassa olevaa henkilöä", "search_no_people": "Ei henkilöitä", "search_no_people_named": "Ei \"{name}\" nimisiä henkilöitä", + "search_options": "Hakuvaihtoehdot", "search_people": "Etsi ihmisiä", "search_places": "Etsi paikkoja", + "search_settings": "Hakuasetukset", "search_state": "Etsi tilaa...", + "search_tags": "Haku tageja...", "search_timezone": "Etsi aikavyöhyke...", "search_type": "Etsinnän tyyppi", "search_your_photos": "Etsi kuvia", @@ -953,6 +1131,7 @@ "see_all_people": "Näytä kaikki henkilöt", "select_album_cover": "Valitse albmin kansi", "select_all": "Valitse kaikki", + "select_all_duplicates": "Valitse kaikki kaksoiskappaleet", "select_avatar_color": "Valitse avatarin väri", "select_face": "Valitse kasvo", "select_featured_photo": "Valitse esittelykuva", @@ -967,6 +1146,7 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_offline": "Serveri Offline-tilassa", "server_online": "Palvelin on linjalla", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", @@ -984,6 +1164,7 @@ "shared_by_user": "Käyttäjän {user} jakama", "shared_by_you": "Sinun jakamasi", "shared_from_partner": "{partner}n kuvia", + "shared_link_options": "Jaetun linkin vaihtoehdot", "shared_links": "Jaetut linkit", "shared_photos_and_videos_count": "{assetCount, plural, other {# jaettua kuvaa ja videota.}}", "shared_with_partner": "Jaa {partner} kanssa", @@ -992,6 +1173,7 @@ "sharing_sidebar_description": "Näytä jakamislinkki sivupalkissa", "shift_to_permanent_delete": "Paina ⇧ poistaaksesi median pysyvästi", "show_album_options": "Näytä albumin asetukset", + "show_albums": "Näytä albumit", "show_all_people": "Näytä kaikki henkilöt", "show_and_hide_people": "Näytä / piilota henkilöitä", "show_file_location": "Näytä tiedostosijainti", @@ -1006,11 +1188,17 @@ "show_person_options": "Näytä henkilöasetukset", "show_progress_bar": "Näytä eteneminen", "show_search_options": "Näytä hakuvaihtoehdot", + "show_supporter_badge": "Kannattajan merkki", + "show_supporter_badge_description": "Näytä kannattajan merkki", "shuffle": "Sekoita", + "sidebar": "Sivupalkki", + "sidebar_display_description": "Näytä linkki näkymään sivupalkissa", "sign_out": "Kirjaudu ulos", "sign_up": "Rekisteröidy", "size": "Koko", "skip_to_content": "Siirry sisältöön", + "skip_to_folders": "Siirry kansioihin", + "skip_to_tags": "Siirry tageihin", "slideshow": "Diaesitys", "slideshow_settings": "Diaesityksen asetukset", "sort_albums_by": "Järjestä albumit...", @@ -1022,6 +1210,8 @@ "sort_title": "Otsikko", "source": "Lähde", "stack": "Pinoa", + "stack_duplicates": "Pinoa kaksoiskappaleet", + "stack_select_one_photo": "Valitse yksi pääkuva pinolle", "stack_selected_photos": "Pinoa valitut kuvat", "stacked_assets_count": "Pinottu {count, plural, one {# media} other {# mediaa}}", "stacktrace": "Vianetsintätiedot", @@ -1041,6 +1231,14 @@ "sunrise_on_the_beach": "Auringonnousu rannalla", "swap_merge_direction": "Käännä yhdistämissuunta", "sync": "Synkronoi", + "tag": "Tagi", + "tag_assets": "Merkitse kohde", + "tag_created": "Luotu tunniste: {tag}", + "tag_feature_description": "Selaa valokuvia ja videoita, jotka on ryhmitelty loogisten tagiotsikoiden mukaan", + "tag_not_found_question": "Etkö löydä tunnistetta? Luo yksi tästä", + "tag_updated": "Päivitetty tunniste: {tag}", + "tagged_assets": "Tunnistettu {count, plural, one {# kohde} other {# kohdetta}}", + "tags": "Tagit", "template": "Template", "theme": "Teema", "theme_selection": "Teeman valinta", @@ -1052,14 +1250,15 @@ "to_change_password": "Vaihda salasana", "to_favorite": "Aseta suosikiksi", "to_login": "Kirjaudu sisään", + "to_parent": "Siirry vanhempaan", "to_trash": "Roskakoriin", "toggle_settings": "Määritä asetukset", - "toggle_theme": "Aseta teema", + "toggle_theme": "Aseta tumma teema", "toggle_visibility": "Aseta näkyvyys", "total_usage": "Käyttö yhteensä", "trash": "Roskakori", "trash_all": "Vie kaikki roskakoriin", - "trash_count": "Vie {count} roskakoriin", + "trash_count": "Roskakori {count, number}", "trash_delete_asset": "Poista / vie roskakoriin", "trash_no_results_message": "Roskakorissa olevat kuvat ja videot näytetään täällä.", "trashed_items_will_be_permanently_deleted_after": "Roskakorin kohteet poistetaan pysyvästi {days, plural, one {# päivän} other {# päivän}} päästä.", @@ -1073,13 +1272,17 @@ "unknown_album": "", "unknown_year": "Tuntematon vuosi", "unlimited": "Rajoittamaton", + "unlink_motion_video": "Poista liikevideon linkitys", "unlink_oauth": "Poista OAuth-linkitys", "unlinked_oauth_account": "Linkittämätön OAuth-tili", "unnamed_album": "Nimetön albumi", + "unnamed_album_delete_confirmation": "Haluatko varmasti poistaa tämän albumin?", "unnamed_share": "Nimetön jako", "unsaved_change": "Tallentamaton muutos", "unselect_all": "Poista valinnat", + "unselect_all_duplicates": "Poista kaikkien kaksoiskappaleiden valinta", "unstack": "Pura pino", + "unstacked_assets_count": "Poistettu pinosta {count, plural, one {# kohde} other {# kohdetta}}", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_decription": "Järjestelmä ei seuraa näitä tiedostoja. Ne voivat johtua epäonnistuneista siirroista, keskeytyneistä latauksista, tai ovat jääneet ohjelmavian seurauksena", "up_next": "Seuraavaksi", @@ -1087,7 +1290,7 @@ "upload": "Siirrä palvelimelle", "upload_concurrency": "Latausten samanaikaisuus", "upload_errors": "Lataus valmistui {count, plural, one {# virheen} other {# virheen}} kanssa. Päivitä sivu nähdäksesi ladatut tiedot.", - "upload_progress": "{remaining} jäljellä - {processed}/{total} käsitelty", + "upload_progress": "Jäljellä {remaining, number} - Käsitelty {processed, number}/{total, number}", "upload_skipped_duplicates": "Ohitettiin {count, plural, one {# kaksoiskappale} other {# kaksoiskappaletta}}", "upload_status_duplicates": "Kaksoiskappaleet", "upload_status_errors": "Virheet", @@ -1099,6 +1302,8 @@ "user": "Käyttäjä", "user_id": "Käyttäjän ID", "user_liked": "{user} tykkäsi {type, select, photo {kuvasta} video {videosta} asset {mediasta} other {tästä}}", + "user_purchase_settings": "Osta", + "user_purchase_settings_description": "Hallitse ostostasi", "user_role_set": "Tee käyttäjästä {user} {role}", "user_usage_detail": "Käyttäjän käytön tiedot", "username": "Käyttäjänimi", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9628573b0d..b86251e039 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pour confirmer, tapez « {email} » ci-dessous", "confirm_reprocess_all_faces": "Êtes-vous sûr de vouloir retraiter tous les visages ? Cela effacera également les personnes déjà identifiées.", "confirm_user_password_reset": "Êtes-vous sûr de vouloir réinitialiser le mot de passe de {user} ?", + "create_job": "Créer une tâche", "crontab_guru": "Générateur de règles Cron", "disable_login": "Désactiver la connexion", "disabled": "Désactivé", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Résolution des miniatures", "image_thumbnail_resolution_description": "Utilisée lors du visionnage de groupes de photos (vue principale, albums, etc.). Une résolution plus élevée préserve davantage de détails, mais est plus longue à encoder, produit des fichiers plus lourds, et peut réduire la réactivité de l'application.", "job_concurrency": "{job} : nombre de tâches simultanées", + "job_created": "Tâche créée", "job_not_concurrency_safe": "Cette tâche ne peut pas être exécutée en multitâche de façon sûre.", "job_settings": "Paramètres des tâches", "job_settings_description": "Gestion des tâches simultanées", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", - "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", + "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", "notification_email_host_description": "Hôte du serveur de messagerie électronique (par exemple, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer les erreurs de certificat", "notification_email_ignore_certificate_errors_description": "Ignorer les erreurs de validation du certificat TLS (non recommandé)", @@ -198,6 +200,7 @@ "password_settings": "Connexion par mot de passe", "password_settings_description": "Gérer les paramètres de connexion par mot de passe", "paths_validated_successfully": "Tous les chemins ont été validés avec succès", + "person_cleanup_job": "Nettoyage des personnes", "quota_size_gib": "Taille du quota (Go)", "refreshing_all_libraries": "Actualisation de toutes les bibliothèques", "registration": "Enregistrement de l'administrateur", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Paramètres réinitialisés avec les derniers paramètres enregistrés", "scanning_library_for_changed_files": "Recherche de fichiers modifiés dans la bibliothèque", "scanning_library_for_new_files": "Recherche de nouveaux fichiers dans la bibliothèque", + "search_jobs": "Recherche des tâches ...", "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", + "tag_cleanup_job": "Nettoyage des étiquettes", "theme_custom_css_settings": "CSS personnalisé", "theme_custom_css_settings_description": "Les feuilles de style en cascade (CSS) permettent de personnaliser l'apparence d'Immich.", "theme_settings": "Paramètres du thème", @@ -312,6 +317,7 @@ "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", + "user_cleanup_job": "Nettoyage des utilisateurs", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -488,8 +494,8 @@ "create_new_person": "Créer une nouvelle personne", "create_new_person_hint": "Attribuer les médias sélectionnés à une nouvelle personne", "create_new_user": "Créer un nouvel utilisateur", - "create_tag": "Créer un tag", - "create_tag_description": "Créer un nouveau tag. Pour les tags imbriqués, veuillez entrer le chemin complet du tag, y compris les \"/\" avant.", + "create_tag": "Créer une étiquette", + "create_tag_description": "Créer une nouvelle étiquette. Pour les étiquettes imbriquées, veuillez entrer le chemin complet de l'étiquette, y compris les caractères \"/\".", "create_user": "Créer un utilisateur", "created": "Créé", "current_device": "Appareil actuel", @@ -513,8 +519,8 @@ "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", "delete_shared_link": "Supprimer le lien partagé", - "delete_tag": "Supprimer le tag", - "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer le tag {tagName} ?", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer l'étiquette {tagName} ?", "delete_user": "Supprimer l'utilisateur", "deleted_shared_link": "Lien partagé supprimé", "description": "Description", @@ -563,7 +569,7 @@ "edit_location": "Modifier la localisation", "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", - "edit_tag": "Modifier le tag", + "edit_tag": "Modifier l'étiquette", "edit_title": "Modifier le title", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", @@ -1141,8 +1147,9 @@ "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", + "search_settings": "Paramètres de recherche", "search_state": "Rechercher par état/région...", - "search_tags": "Recherche de tags...", + "search_tags": "Recherche d'étiquettes...", "search_timezone": "Rechercher par fuseau horaire...", "search_type": "Rechercher par type", "search_your_photos": "Rechercher vos photos", @@ -1218,7 +1225,7 @@ "size": "Taille", "skip_to_content": "Passer", "skip_to_folders": "Passer vers les dossiers", - "skip_to_tags": "Passer vers les tags", + "skip_to_tags": "Passer vers les étiquettes", "slideshow": "Diaporama", "slideshow_settings": "Paramètres du diaporama", "sort_albums_by": "Trier les albums par...", @@ -1253,12 +1260,12 @@ "sync": "Synchroniser", "tag": "Tag", "tag_assets": "Taguer les médias", - "tag_created": "Tag créé : {tag}", + "tag_created": "Étiquette créée : {tag}", "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", - "tag_not_found_question": "Vous ne trouvez pas un tag ? Créez-en un ici", - "tag_updated": "Tag mis à jour : {tag}", + "tag_not_found_question": "Vous ne trouvez pas une étiquette ? Créez-en une ici", + "tag_updated": "Étiquette mise à jour : {tag}", "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", - "tags": "Tags", + "tags": "Étiquettes", "template": "Modèle", "theme": "Thème", "theme_selection": "Sélection du thème", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index 05eab7a804..62c30461a5 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -41,6 +41,7 @@ "confirm_email_below": "כדי לאשר, יש להקליד \"{email}\" למטה", "confirm_reprocess_all_faces": "האם את/ה בטוח/ה שברצונך לעבד מחדש את כל הפנים? זה גם ינקה אנשים בעלי שם.", "confirm_user_password_reset": "האם את/ה בטוח/ה שברצונך לאפס את הסיסמה של המשתמש {user}?", + "create_job": "צור עבודה", "crontab_guru": "Crontab Guru", "disable_login": "השבת כניסה", "disabled": "מושבת", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "רזולוציית תמונה ממוזערת", "image_thumbnail_resolution_description": "משמש בעת צפייה בקבוצות של תמונות (ציר זמן ראשי, תצוגת אלבום וכו'). רזולוציות גבוהות יותר יכולות לשמר פירוט רב יותר אך לוקחות יותר זמן לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", "job_concurrency": "בו-זמניות של {job}", + "job_created": "עבודה נוצרה", "job_not_concurrency_safe": "משימה זו אינה בטוחה במקביל.", "job_settings": "הגדרות משימה", "job_settings_description": "ניהול בו-זמניות של משימה", @@ -198,6 +200,7 @@ "password_settings": "סיסמת התחברות", "password_settings_description": "נהל הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", + "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", "refreshing_all_libraries": "מרענן את כל הספריות", "registration": "רישום מנהל מערכת", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", "scanning_library_for_changed_files": "סורק ספרייה לאיתור קבצים שהשתנו", "scanning_library_for_new_files": "סורק ספרייה לאיתור קבצים חדשים", + "search_jobs": "חיפוש עבודות...", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", + "tag_cleanup_job": "ניקוי תגים", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", @@ -312,6 +317,7 @@ "trash_settings_description": "נהל את הגדרות האשפה", "untracked_files": "קבצים ללא מעקב", "untracked_files_description": "קבצים אלה אינם נמצאים במעקב של היישום. הם יכולים להיות תוצאות של העברות כושלות, העלאות שנקטעו, או שנותרו מאחור בגלל שיבוש בתוכנה", + "user_cleanup_job": "ניקוי משתמשים", "user_delete_delay": "החשבון והנכסים של {user} יתוזמנו למחיקה לצמיתות בעוד {delay, plural, one {יום #} other {# ימים}}.", "user_delete_delay_settings": "עיכוב מחיקה", "user_delete_delay_settings_description": "מספר הימים לאחר ההסרה עד מחיקה לצמיתות של החשבון והנכסים של המשתמש. משימת מחיקת המשתמש פועלת בחצות כדי לבדוק אם יש משתמשים שמוכנים למחיקה. שינויים בהגדרה זו יוערכו בביצוע הבא.", @@ -1141,6 +1147,7 @@ "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", + "search_settings": "הגדרות חיפוש", "search_state": "חפש מדינה...", "search_tags": "חיפוש תגים...", "search_timezone": "חפש אזור זמן...", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 954eeff202..4247de3c42 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Za potvrdu upišite \"{email}\" ispod", "confirm_reprocess_all_faces": "Jeste li sigurni da želite ponovno obraditi sva lica? Ovo će također obrisati imenovane osobe.", "confirm_user_password_reset": "Jeste li sigurni da želite poništiti lozinku korisnika {user}?", + "create_job": "Izradi zadatak", "crontab_guru": "Crontab Guru", "disable_login": "Onemogući prijavu", "duplicate_detection_job_description": "Pokrenite strojno učenje na materijalima kako biste otkrili slične slike. Oslanja se na Pametno Pretraživanje", @@ -69,6 +70,7 @@ "image_thumbnail_resolution": "Razlučivost sličica", "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", "job_concurrency": "{job} istovremenost", + "job_created": "Zadatak je kreiran", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", "job_settings": "Postavke posla", "job_settings_description": "Upravljajte istovremenošću poslova", @@ -91,8 +93,8 @@ "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", "logging_enable_description": "Omogući zapisivanje", - "logging_level_description": "Kada je omogućeno, koju razinu zapisavanje koristiti.", - "logging_settings": "Zapisavanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisivanja koristiti.", + "logging_settings": "Zapisivanje", "machine_learning_clip_model": "CLIP model", "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", "machine_learning_duplicate_detection": "Detekcija Duplikata", @@ -138,7 +140,7 @@ "map_settings_description": "Upravljanje postavkama karte", "map_style_description": "URL na style.json temu karte", "metadata_extraction_job": "Izdvoj metapodatke", - "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS, lica i rezolucija", "metadata_faces_import_setting": "Omogući uvoz lica", "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", "metadata_settings": "Postavke Metapodataka", @@ -197,6 +199,7 @@ "password_settings": "Prijava zaporkom", "password_settings_description": "Upravljanje postavkama za prijavu zaporkom", "paths_validated_successfully": "Sve su putanje uspješno potvrđene", + "person_cleanup_job": "Čišćenje lica", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvježavanje svih biblioteka", "registration": "Registracija administratora", @@ -210,6 +213,7 @@ "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", "scanning_library_for_changed_files": "Skeniranje biblioteke za promijenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži zadatke…", "send_welcome_email": "Pošaljite email dobrodošlice", "server_external_domain_settings": "Vanjska domena", "server_external_domain_settings_description": "Domena za javno dijeljene linkove, uključujući http(s)://", @@ -237,6 +241,7 @@ "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", "storage_template_user_label": "{label} je korisnička oznaka za pohranu", "system_settings": "Postavke Sustava", + "tag_cleanup_job": "Čišćenje oznaka", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", "theme_settings": "Postavke tema", @@ -310,6 +315,7 @@ "trash_settings_description": "Upravljanje postavkama smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Brisanje odgode", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", @@ -449,7 +455,7 @@ "clear_value": "Očisti vrijednost", "clockwise": "U smjeru kazaljke na satu", "close": "Zatvori", - "collapse": "Sažimanje", + "collapse": "Sažmi", "collapse_all": "Sažmi sve", "color": "Boja", "color_theme": "Tema boja", @@ -918,98 +924,179 @@ "owner": "Vlasnik", "partner": "Partner", "partner_can_access": "{partner} može pristupiti", - "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "partner_can_access_assets": "Sve vaše fotografije i videi osim onih u arhivi i smeću", + "partner_can_access_location": "Mjesto otkuda je slika otkinuta", + "partner_sharing": "Dijeljenje s partnerom", + "partners": "Partneri", + "password": "Zaporka", + "password_does_not_match": "Zaporka se ne podudara", + "password_required": "Zaporka je obavezna", + "password_reset_success": "Reset zaporke je uspješan", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {Prošli dan} few {Prošlih # dana} other {Prošlih # dana}}", + "hours": "{hours, plural, one {Prošli sat} few {Prošla # sata} other {Prošlih # sati}}", + "years": "{years, plural, one {Prošle godine} few {Prošle # godine} other {Prošlih # godina}}" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "remove_offline_files": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "path": "Putanja", + "pattern": "Uzorak", + "pause": "Pauza", + "pause_memories": "Pauziraj sjećanja", + "paused": "Pauzirano", + "pending": "Na čekanju", + "people": "Ljudi", + "people_edits_count": "Izmjenjeno {count, plural, one {# osoba} other {# osobe}}", + "people_feature_description": "Pregledavanje fotografija i videozapisa grupiranih po osobama", + "people_sidebar_description": "Prikažite poveznicu na Osobe na bočnoj traci", + "permanent_deletion_warning": "Upozorenje za nepovratno brisanje", + "permanent_deletion_warning_setting_description": "Prikaži upozorenje prilikom trajnog brisanja sredstava", + "permanently_delete": "Nepovratno obriši", + "permanently_delete_assets_count": "Trajno izbriši {count, plural, one {datoteku} other {datoteke}}", + "permanently_delete_assets_prompt": "Da li ste sigurni da želite trajni izbrisati {count, plural, one {ovu datoteku?} other {ove # datoteke?}}Ovo će ih također ukloniti {count, plural, one {iz njihovog} other {iz njihovih}} albuma.", + "permanently_deleted_asset": "Trajno izbrisano sredstvo", + "permanently_deleted_assets_count": "Trajno izbrisano {count, plural, one {# datoteka} other {# datoteke}}", + "person": "Osoba", + "person_hidden": "{name}{hidden, select, true { (skriveno)} other {}}", + "photo_shared_all_users": "Čini se da ste svoje fotografije podijelili sa svim korisnicima ili nemate nijednog korisnika s kojim biste ih podijelili.", + "photos": "Fotografije", + "photos_and_videos": "Fotografije i videozapisi", + "photos_count": "{count, plural, one {{count, number} fotografija} few {{count, number} fotografije} other {{count, number} fotografija}}", + "photos_from_previous_years": "Fotografije iz prethodnih godina", + "pick_a_location": "Odaberite lokaciju", + "place": "Mjesto", + "places": "Mjesta", + "play": "Pokreni", + "play_memories": "Pokreni sjećanja", + "play_motion_photo": "Reproduciraj Pokretnu fotografiju", + "play_or_pause_video": "Reproducirajte ili pauzirajte video", + "port": "Port", + "preset": "Unaprijed postavljeno", + "preview": "Pregled", + "previous": "Prethodno", + "previous_memory": "Prethodno sjećanje", + "previous_or_next_photo": "Prethodna ili sljedeća fotografija", + "primary": "Primarna (Primary)", + "privacy": "Privatnost", + "profile_image_of_user": "Profilna slika korisnika {user}", + "profile_picture_set": "Profilna slika postavljena.", + "public_album": "Javni album", + "public_share": "Javno dijeljenje", + "purchase_account_info": "Podržava softver", + "purchase_activated_subtitle": "Hvala što podržavate Immich i softver otvorenog koda", + "purchase_activated_time": "Aktivirano {date, date}", + "purchase_activated_title": "Vaš ključ je uspješno aktiviran", + "purchase_button_activate": "Aktiviraj", + "purchase_button_buy": "Kupi", + "purchase_button_buy_immich": "Kupi Immich", + "purchase_button_never_show_again": "Nikad više ne prikazuj", + "purchase_button_reminder": "Podsjeti me za 30 dana", + "purchase_button_remove_key": "Ukloni ključ", + "purchase_button_select": "Odaberite", + "purchase_failed_activation": "Aktivacija nije uspjela! Provjerite svoju e-poštu za točan ključ proizvoda!", + "purchase_individual_description_1": "Za pojedinca", + "purchase_individual_description_2": "Status podržavanja", + "purchase_individual_title": "Pojedinačna licenca", + "purchase_input_suggestion": "Imate ključ proizvoda? Unesite ključ ispod", + "purchase_license_subtitle": "Kupite Immich kako biste podržali kontinuirani razvoj usluge", + "purchase_lifetime_description": "Doživotna kupnja", + "purchase_option_title": "MOGUĆNOSTI KUPNJE", + "purchase_panel_info_1": "Za izgradnju Immicha potrebno je puno vremena i truda, a mi imamo inženjere koji rade na tome s punim radnim vremenom kako bismo ga učinili što boljim. Naša je misija da softver otvorenog koda i etička poslovna praksa postanu održivi izvor prihoda za programere i da se stvori ekosustav koji poštuje privatnost sa stvarnim alternativama eksploatacijskim uslugama u oblaku.", + "purchase_panel_info_2": "Budući da se obvezujemo da nećemo dodavati dodatne pretplate, ova vam kupnja neće dodijeliti nikakve dodatne značajke u Immichu. Oslanjamo se na korisnike poput vas da podržimo stalni razvoj Immicha.", + "purchase_panel_title": "Podrži projekt", + "purchase_per_server": "Po serveru", + "purchase_per_user": "Po korisniku", + "purchase_remove_product_key": "Ukloni ključ proizvoda", + "purchase_remove_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda?", + "purchase_remove_server_product_key": "Uklonite ključ proizvoda poslužitelja (Server)", + "purchase_remove_server_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda poslužitelja (Server)?", + "purchase_server_description_1": "Za cijeli server", + "purchase_server_description_2": "Status podupiratelja", + "purchase_server_title": "Poslužitelj (Server)", + "purchase_settings_server_activated": "Ključem proizvoda poslužitelja upravlja administrator", + "rating": "Broj zvjezdica", + "rating_clear": "Obriši ocjenu", + "rating_count": "{count, plural, one {# zvijezda} other {# zvijezde}}", + "rating_description": "Prikaži EXIF ocjenu na info ploči", + "reaction_options": "Mogućnosti reakcije", + "read_changelog": "Pročitajte Dnevnik promjena", + "reassign": "Ponovno dodijeli", + "reassigned_assets_to_existing_person": "Ponovo dodijeljeno{count, plural, one {# datoteka} other {# datoteke}} postojećoj {name, select, null {osobi} other {{name}}}", + "reassigned_assets_to_new_person": "Ponovo dodijeljeno {count, plural, one {# datoteka} other {# datoteke}} novoj osobi", + "reassing_hint": "Dodijelite odabrane datoteke postojećoj osobi", + "recent": "Nedavno", + "recent_searches": "Nedavne pretrage", + "refresh": "Osvježi", + "refresh_encoded_videos": "Osvježite kodirane videozapise", + "refresh_metadata": "Osvježi metapodatke", + "refresh_thumbnails": "Osvježi sličice", + "refreshed": "Osvježeno", + "refreshes_every_file": "Osvježava svaku datoteku", + "refreshing_encoded_video": "Osvježavanje kodiranog videa", + "refreshing_metadata": "Osvježavanje metapodataka", + "regenerating_thumbnails": "Obnavljanje sličica", + "remove": "Ukloni", + "remove_assets_album_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz albuma?", + "remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?", + "remove_assets_title": "Ukloniti datoteke?", + "remove_custom_date_range": "Ukloni prilagođeni datumski raspon", + "remove_from_album": "Ukloni iz albuma", + "remove_from_favorites": "Ukloni iz favorita", + "remove_from_shared_link": "Ukloni iz dijeljene poveznice", + "remove_offline_files": "Ukloni izvanmrežne datoteke", + "remove_user": "Ukloni korisnika", + "removed_api_key": "Uklonjen API ključ: {name}", + "removed_from_archive": "Uklonjeno iz arhive", + "removed_from_favorites": "Uklonjeno iz favorita", + "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", + "rename": "Preimenuj", + "repair": "Popravi", + "repair_no_results_message": "Nepraćene datoteke i datoteke koje nedostaju pojavit će se ovdje", + "replace_with_upload": "Zamijeni s prijenosom", + "repository": "Spremište (Repository)", + "require_password": "Zahtijevaj lozinku", + "require_user_to_change_password_on_first_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", + "reset": "Reset", + "reset_password": "Resetiraj lozinku", + "reset_people_visibility": "Poništi vidljivost ljudi", + "reset_to_default": "Vrati na zadano", + "resolve_duplicates": "Riješite duplikate", + "resolved_all_duplicates": "Razriješi sve duplikate", + "restore": "Oporavi", + "restore_all": "Oporavi sve", + "restore_user": "Vrati korisnika", + "restored_asset": "Obnovljena datoteka", + "resume": "Nastavi", + "retry_upload": "Ponovi prijenos", + "review_duplicates": "Pregledajte duplikate", + "role": "Uloga", + "role_editor": "Urednik", + "role_viewer": "Gledatelj", + "save": "Spremi", + "saved_api_key": "Spremljen API ključ", + "saved_profile": "Spremljen profil", + "saved_settings": "Spremljene postavke", + "say_something": "Reci nešto", + "scan_all_libraries": "Skeniraj sve Knjižnice", + "scan_all_library_files": "Ponovno skenirajte sve datoteke Knjižnice", + "scan_new_library_files": "Skeniraj nove datoteke Knjižnice", + "scan_settings": "Postavke skeniranja", + "scanning_for_album": "Skeniranje albuma...", + "search": "Pretraživanje", + "search_albums": "Traži albume", + "search_by_context": "Pretraživanje po kontekstu", + "search_by_filename": "Pretražujte prema nazivu datoteke ili ekstenziji", + "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", + "search_camera_make": "Pretražite marku kamere...", + "search_camera_model": "Pretražite model kamere...", + "search_city": "Pretražite grad...", + "search_country": "Pretražite državu...", + "search_for_existing_person": "Potražite postojeću osobu", + "search_no_people": "Nema ljudi", + "search_no_people_named": "Nema osoba s imenom \"{name}\"", + "search_options": "Opcije pretraživanja", + "search_people": "Traži ljude", + "search_places": "Traži mjesta", + "search_settings": "Postavke pretraživanja", "search_state": "", "search_timezone": "", "search_type": "", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 249b663a77..851171bc99 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "Az újbóli engedélyezéshez használjon egySzerver Parancsot.", "background_task_job": "Háttérfolyamatok", "check_all": "Összes Kipiálása", - "cleared_jobs": "{job} munkák kitörölve", + "cleared_jobs": "{job}: feladatok törölve", "config_set_by_file": "A konfigurációt jelenleg egy konfigurációs fájl állítja be", "confirm_delete_library": "Biztosan ki szeretné törölni a {library} képtárat?", "confirm_delete_library_assets": "Biztosan kitörli ezt a képtárat? Ez kitöröl {count, plural, one {#} other {#}} benne lévő fájlt az Immichből és nem visszavonható. A fájlok a lemezen maradnak.", "confirm_email_below": "A megerősítéshez írja \"{email}\"-t alább", "confirm_reprocess_all_faces": "Biztos benne, hogy újra szeretné feldolgozni az összes arcot? Ez a megnevezett személyeket is törli.", "confirm_user_password_reset": "Biztosan vissza szeretné állítani {user} jelszavát?", + "create_job": "Feladat létrehozása", "crontab_guru": "Crontab Guru", "disable_login": "Belépés letiltása", "disabled": "Letiltva", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Bélyegkép felbontás", "image_thumbnail_resolution_description": "Képek csoportosított nézetekor használatos (idővonal, album nézet stb). Nagyobb felbontás esetén a kép részletgazdagabb marad, de tovább tart elkészíteni, nagyobb fájl méretet eredményes, és ronthatja az alkalmazás reagálását.", "job_concurrency": "{job} párhuzamosság", + "job_created": "Feladat létrehozva", "job_not_concurrency_safe": "Ez a feladat nem párhuzamosság-biztos.", "job_settings": "Feladat beállítások", "job_settings_description": "Feladatok párhuzamosságának beállítása", @@ -96,8 +98,8 @@ "logging_settings": "Naplózás", "machine_learning_clip_model": "CLIP modell", "machine_learning_clip_model_description": "Egy CLIP modell neve az itt felsoroltak közül. A modell megváltoztatása után újra kell futtatni az 'Okos Keresés' munkát minden képre.", - "machine_learning_duplicate_detection": "Másolatok Észlelése", - "machine_learning_duplicate_detection_enabled": "Másolatkeresés engedélyezése", + "machine_learning_duplicate_detection": "Duplikáltak Észlelése", + "machine_learning_duplicate_detection_enabled": "Duplikáltak keresésének engedélyezése", "machine_learning_duplicate_detection_enabled_description": "Ha ki van kapcsolva, a pontosan azonos fájlok akkor sem lesznek duplikálva.", "machine_learning_duplicate_detection_setting_description": "CLIP beágyazások használata a valószínű másolatok kereséséhez", "machine_learning_enabled": "Gépi tanulás engedélyezése", @@ -107,7 +109,7 @@ "machine_learning_facial_recognition_model": "Arcfelismerési modell", "machine_learning_facial_recognition_model_description": "A modellek méret szerint csökkenő sorrendben vannak felsorolva. A nagyobb modellek lassabbak és több memóriát használnak, de jobb eredményt produkálnak. Modellváltás után az összes képen újra le kell futtatni az arcfelismerési feladatot.", "machine_learning_facial_recognition_setting": "Arckeresés engedélyezése", - "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Felfedezés oldalon az Személyek szekcióban nem fog szerepelni senki.", + "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Böngészés oldalon az Személyek szekcióban nem fog szerepelni senki.", "machine_learning_max_detection_distance": "Maximum észlelési távolság", "machine_learning_max_detection_distance_description": "Két kép közötti maximális távolság, amely esetében még másolatnak tekintjük őket (0.001 és 0.1 közötti érték). Magasabb értékek több másolatot találnak meg, de a hamis találatok esélye is nagyobb.", "machine_learning_max_recognition_distance": "Maximum felismerési távolság", @@ -138,11 +140,14 @@ "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", - "metadata_extraction_job": "Metaadatok feldolgozása", - "metadata_extraction_job_description": "Metaadat-információk kinyerése minden tartalomból, például GPS, arcok és felbontás", + "metadata_extraction_job": "Metaadatok kinyerése", + "metadata_extraction_job_description": "Metaadat-információk kinyerése minden fájlból, például GPS, arcok és felbontás", + "metadata_faces_import_setting": "Arc importálás engedélyezése", + "metadata_faces_import_setting_description": "Arcok importálása a kép Exif adatából és metaadat fájlokból", "metadata_settings": "Metaadat beállítások", - "migration_job": "Migráció", - "migration_job_description": "Az képi vagyon és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", + "metadata_settings_description": "Metaadat-beállítások kezelése", + "migration_job": "Migrálás", + "migration_job_description": "A fájlok és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", "no_paths_added": "Nincs megadva elérési útvonal", "no_pattern_added": "Nincs megadva illesztési minta (pattern)", "note_apply_storage_label_previous_assets": "Megjegyzés: Tárolási Cimkék már korábban feltöltött képi vagyonra ragasztásához futtasd a következőt -", @@ -175,7 +180,7 @@ "oauth_issuer_url": "Kibocsátó URL", "oauth_mobile_redirect_uri": "Mobil átirányítási URI", "oauth_mobile_redirect_uri_override": "Mobil átirányítási URI felülírás", - "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az 'app.immich:/' érvénytelen átirányítási URI.", + "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az OAuth szolgáltató tiltja a mobil URI-t, mint például '{callback}'", "oauth_profile_signing_algorithm": "Profil aláíró algoritmus", "oauth_profile_signing_algorithm_description": "A felhasználói profil aláírásához használt algoritmus.", "oauth_scope": "Hatókör", @@ -195,6 +200,7 @@ "password_settings": "Jelszavas Bejelentkezés", "password_settings_description": "Jelszavas bejelentkezés beállítások kezelése", "paths_validated_successfully": "Összes útvonal sikeresen érvényesítve", + "person_cleanup_job": "Személy törlése", "quota_size_gib": "Kvóta Mérete (GiB)", "refreshing_all_libraries": "Összes képtár újratöltése", "registration": "Admin Regisztráció", @@ -208,6 +214,7 @@ "reset_settings_to_recent_saved": "Beállítások visszaállítása a legutóbb mentettre", "scanning_library_for_changed_files": "Képtár átfésülése megváltozott fájlok után", "scanning_library_for_new_files": "Képtár átfésülése új fájlok után", + "search_jobs": "Feladat keresés...", "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", @@ -215,10 +222,10 @@ "server_settings_description": "Szerver beállítások kezelése", "server_welcome_message": "Üdvözlő üzenet", "server_welcome_message_description": "A bejelentkezőoldalon megjelenő üzenet.", - "sidecar_job": "Oldalkocsi fájl metaadatok", - "sidecar_job_description": "Fedezze fel vagy szinkronizálja az oldalkocsi fájlokban tárolt metaadatokat a fájlrendszerből", + "sidecar_job": "Metaadat feldolgozás", + "sidecar_job_description": "Metaadatok keresése vagy szinkronizálása a fájlrendszer alapján", "slideshow_duration_description": "Az egyes képek megjelenítésének ideje másodpercben", - "smart_search_job_description": "Futtasson gépi tanulást a képi vagyonon az intelligens keresés támogatása érdekében", + "smart_search_job_description": "Gépi tanulás futtatása a fájlokon az okos keresés támogatásához", "storage_template_date_time_description": "A fájl készítési időpontja lesz felhasználva az időpont információhoz", "storage_template_date_time_sample": "Példa időpont {date}", "storage_template_enable_description": "Tárolási sablon motor engedélyezése", @@ -235,13 +242,14 @@ "storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét", "storage_template_user_label": "A felhasználó Tároló Címkéje {label}", "system_settings": "Rendszerbeállítások", + "tag_cleanup_job": "Címke törlés", "theme_custom_css_settings": "Egyedi CSS", "theme_custom_css_settings_description": "CSS Stíluslapokkal az Immich stílusa megváltoztatható.", "theme_settings": "Stílus Beállítások", "theme_settings_description": "Kezelje az Immich webes felület testreszabását", "these_files_matched_by_checksum": "Ezek a fájlok egyeznek az ellenőrző összegük alapján", "thumbnail_generation_job": "Bélyegképek Generálása", - "thumbnail_generation_job_description": "Hozzon létre nagy, kicsi és elmosódott bélyegképeket minden egyes elemhez, valamint bélyegképeket minden egyes személyhez", + "thumbnail_generation_job_description": "Nagy, kicsi és elmosódott bélyegképek létrehozása minden elemhez, valamint bélyegképek generálása minden személyhez", "transcode_policy_description": "", "transcoding_acceleration_api": "Gyorsító API", "transcoding_acceleration_api_description": "Az API, amely interakcióba lép az eszközzel az átkódolás felgyorsítása érdekében. Ez a beállítás a „legtöbb, amit megtehetünk” alapon működik: hiba esetén visszaáll a szoftveres átkódolásra. A VP9 a hardvertől függően vagy működik, vagy nem.", @@ -257,11 +265,11 @@ "transcoding_accepted_video_codecs_description": "Válassza ki, mely videó kodexeket nem kell átkódolni. Csak bizonyos átkódolási szabályzatokhoz használatos.", "transcoding_advanced_options_description": "Ezeket az opciókat a legtöbb felhasználónak nem kell módosítania", "transcoding_audio_codec": "Audio kodek", - "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb minőség ugyanannyi helyet foglalva), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", + "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb hangminőség ugyanakkora tárhelyen), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", "transcoding_bitrate_description": "A maximum bitrátát meghaladó vagy nem megfelelő formátumú videókat", "transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a H.264 kodekhez, a HEVC kodekhez és a VP9 kodekhez.", "transcoding_constant_quality_mode": "Állandó minőségi mód", - "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.", + "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont az előbbit nem minden hardver támogatja. A rendszer az itt beállított módot preferálja a minőség orientált enkódoláshoz. Az NVENC nem használja ezt a beállítást, mivel nem támogatja az ICQ-t.", "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", "transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", "transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen", @@ -287,7 +295,7 @@ "transcoding_settings": "Videó Transzkódolási Beállítások", "transcoding_settings_description": "Videófájlok felbontásának és kódólásának kezelése", "transcoding_target_resolution": "Célfelbontás", - "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás teljesítményét.", + "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás válaszidejét.", "transcoding_temporal_aq": "Időbeli (Temporal) AQ", "transcoding_temporal_aq_description": "Csak NVENC esetén. Növeli a nagyon részletes, keveset mozgó videóanyag minőségét. Nem minden régi hardver támogatja.", "transcoding_threads": "Folyamatok száma", @@ -299,16 +307,17 @@ "transcoding_transcode_policy": "Transzkódolási szabályzat", "transcoding_transcode_policy_description": "Mely videókat transzkódolja. HDR videók mindig transzkódolásra kerülnek (kivéve, ha a transzkódolás ki van kapcsolva).", "transcoding_two_pass_encoding": "Enkódolás két menetben", - "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videok jobbak. Ha engedélyezve van a bitráta maximalizálása (amely egyébként szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et és a maximális bitráta alapján választja ki a megfelelő bitráta sávot. VP9 használata esetén CRF használható, ha a bitráta nincs maxmalizáva (ki van kapcsolva).", + "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videók jobb minőségűek. Ha engedélyezve van a bitráta maximalizálása (amely szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et. VP9 használata esetén a CRF használható, ha a bitráta nincs maximalizálva (ki van kapcsolva).", "transcoding_video_codec": "Videó Kodek", "transcoding_video_codec_description": "VP9 hatékonyabb és kompatibilisebb webre, de tovább tart a transzkódolás. HEVC hasonló teljesítményű, de több web kompatibilitási problémát okozhat. H.264 széles körben kompatibilis és gyors a transzkódolása, de sokkal nagyobb fájlokat készít. AV1 a leghatékonyabb kodek, de régebbi eszközök nem támogatják.", "trash_enabled_description": "Lomtár engedélyezése", "trash_number_of_days": "Napok száma", - "trash_number_of_days_description": "Hány napig legyenek a lomtárban tárolva a törölt képek, videok, mielőtt véglegesen kiürítődnek", + "trash_number_of_days_description": "Hány napig legyenek a lomtárban a fájlok a végleges törlés előtt", "trash_settings": "Lomtár Beállítások", "trash_settings_description": "Lomtár beállítások kezelése", "untracked_files": "Nem kezelt fájlok", "untracked_files_description": "Ezekkel a fájlokkal semmit nem csinál az alkalmazás. Ez lehetséges pl. meghiúsult mozgatás, megszakított feltöltés miatt, vagy valamilyen alkalmazáshiba következtében", + "user_cleanup_job": "Felhasználó adatainak törlése", "user_delete_delay": "{user} felhasználói fiókja és képi vagyona véglegesen törölve lesz {delay, plural, one {# nap} other {# nap}} múlva.", "user_delete_delay_settings": "Törlési késleltetés", "user_delete_delay_settings_description": "Ennyi nap teljen el az eltávolítás után a felhasználói fiók és ahhoz tartozó elemek végleges törlése között. A törlésért felelős folyamat éjfélkor indul, és megnézi van-e törlésre kész felhasználó. A beállítás változtatása a következő végrehajtás során lép életbe.", @@ -339,7 +348,7 @@ "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", - "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a {album} albumot?", + "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a(z) {album} albumot?", "album_delete_confirmation_description": "Amennyiben ez egy megosztott album, a többi felhasználó sem fog tudni hozzáférni.", "album_info_updated": "Album infó frissítve", "album_leave": "Elhagyja az albumot?", @@ -376,7 +385,7 @@ "archive_size": "Archívum mérete", "archive_size_description": "Beállítja letöltésnél az archívum méretét (GiB)", "archived": "Archíválva", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, other {Archiválva #}}", "are_these_the_same_person": "Ugyanaz a személy?", "are_you_sure_to_do_this": "Biztosan ezt akarod csinálni?", "asset_added_to_album": "Hozzáadva az albumhoz", @@ -388,6 +397,7 @@ "asset_offline": "Elem offline", "asset_offline_description": "Ez az elem nem elérhető. Immich nem képes elérni a file helyét. Győződjön meg az elem elérhetőségéről és szkennelje újra a könyvtárat.", "asset_skipped": "Kihagyva", + "asset_skipped_in_trash": "Lomtárban", "asset_uploaded": "Feltöltve", "asset_uploading": "Feltöltés...", "assets": "elemek", @@ -396,12 +406,12 @@ "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva a(z) {hasName, select, true {{name}} other {új}} albumba", "assets_count": "{count, plural, other {# elem}}", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", - "assets_moved_to_trash_count": "{count, plural, other {# elem}} szemétbe mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} lomtárba mozgatva", "assets_permanently_deleted_count": "{count, plural, other {# elem}} örökre törölve", "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", - "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", + "assets_restore_confirmation": "Biztosan visszaállítja a lomtárban lévő elemeket? Ez a művelet nem visszavonható!", "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", - "assets_trashed_count": "{count, plural, other {# elem}} kidobva", + "assets_trashed_count": "{count, plural, other {# elem}} lomtárba helyezve", "assets_were_part_of_album_count": "{count, plural, other {# elem}} már az album része volt", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", @@ -484,6 +494,8 @@ "create_new_person": "Új személy létrehozása", "create_new_person_hint": "A kiválasztott képekhez új személyt rendel hozzá", "create_new_user": "Új felhasználó létrehozása", + "create_tag": "Címke létrehozása", + "create_tag_description": "Új címke létrehozása. Beágyazott címkék esetén adja meg a címke teljes elérési útvonalát, beleértve a perjeleket is.", "create_user": "Felhasználó létrehozása", "created": "Készült", "current_device": "Ez az eszköz", @@ -507,6 +519,8 @@ "delete_library": "Képtár törlése", "delete_link": "Link törlése", "delete_shared_link": "Megosztott link törlése", + "delete_tag": "Címke törlése", + "delete_tag_confirmation_prompt": "Biztos, hogy törölni szeretné a {tagName} címkét?", "delete_user": "Felhasználó törlése", "deleted_shared_link": "Törölt megosztott link", "description": "Leírás", @@ -555,6 +569,7 @@ "edit_location": "Hely módosítása", "edit_name": "Név módosítása", "edit_people": "Személyek módosítása", + "edit_tag": "Címke szerkesztése", "edit_title": "Cím Módosítása", "edit_user": "Felhasználó módosítása", "edited": "Módosítva", @@ -567,7 +582,7 @@ "empty": "", "empty_album": "Üres Album", "empty_trash": "Lomtár Ürítése", - "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárbeli fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", + "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárban lévő fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", "enable": "Engedélyezés", "enabled": "Engedélyezve", "end_date": "Vég dátum", @@ -585,7 +600,7 @@ "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", "cant_search_people": "Emberek keresése sikertelen", "cant_search_places": "Helyek keresése sikertelen", - "cleared_jobs": "A {job} munkák törölve", + "cleared_jobs": "A {job} feladatok törölve", "error_adding_assets_to_album": "Hiba történt az elemek albumhoz való hozzáadása során", "error_adding_users_to_album": "Hiba történt a felhasználók albumhoz való hozzáadása során", "error_deleting_shared_user": "Hiba történt megosztott felhasználó törlése során", @@ -594,7 +609,7 @@ "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", - "failed_job_command": "Parancs {command} hibával zárult a {job} munkában", + "failed_job_command": "A(z) {command} parancs hibával zárult a(z) {job} feladatban", "failed_to_create_album": "Album készítése sikertelen", "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", @@ -652,6 +667,7 @@ "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_motion_video": "Nem lehet a motion videót hozzákapcsolni", "unable_to_link_oauth_account": "OAuth felhasználó csatlakoztatása sikertelen", "unable_to_load_album": "Album betöltése sikertelen", "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", @@ -689,9 +705,10 @@ "unable_to_scan_library": "Könyvtár ellenőrzése sikertelen", "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", - "unable_to_submit_job": "Nem sikerült a profilt elmenteni", + "unable_to_submit_job": "Nem sikerült a feladatot elindítani", "unable_to_trash_asset": "Nem sikerült a fájl lomtárba mozgatása", "unable_to_unlink_account": "Nem sikerült a fiók lekapcsolása", + "unable_to_unlink_motion_video": "Nem lehet a motion videót leválasztani", "unable_to_update_album_cover": "Albumborító beállítása sikertelen", "unable_to_update_album_info": "Album információ frissítése sikertelen", "unable_to_update_library": "Nem sikerült a képtár módosítása", @@ -711,7 +728,8 @@ "expire_after": "Lejárati idő", "expired": "Lejárt", "expires_date": "Lejár {date}", - "explore": "Felfedezés", + "explore": "Böngészés", + "explorer": "Böngésző", "export": "Exportálás", "export_as_json": "Exportálás JSON formátumban", "extension": "Kiterjesztés", @@ -725,6 +743,8 @@ "feature": "", "feature_photo_updated": "Címlapkép frissítve", "featurecollection": "", + "features": "Jellemzők", + "features_setting_description": "Az alkalmazás lehetőségeinek kezelése", "file_name": "Fájlnév", "file_name_or_extension": "Fájlnév vagy kiterjesztés", "filename": "Fájlnév", @@ -733,6 +753,8 @@ "filter_people": "Személyek szűrése", "find_them_fast": "Kereséssel gyorsan megtalálhatóak név alapján", "fix_incorrect_match": "Hibás találat korrigálása", + "folders": "Mappák", + "folders_feature_description": "A fájlrendszerben lévő fényképek és videók mappanézetben való böngészése", "force_re-scan_library_files": "Az összes Képtár fájl újbóli átfésülésének indítása", "forward": "Előre", "general": "Általános", @@ -753,7 +775,7 @@ "hide_password": "Jelszó elrejtése", "hide_person": "Személy elrejtése", "hide_unnamed_people": "Megnevezetlen emberek elrejtése", - "host": "", + "host": "Kiszolgáló", "hour": "Óra", "image": "Kép", "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma {date}", @@ -804,6 +826,7 @@ "library_options": "Képtár beállítások", "light": "Világos", "like_deleted": "Tetszik törölve", + "link_motion_video": "Motion videó hozzárendelése", "link_options": "Link beállítások", "link_to_oauth": "Csatlakoztatás OAuth-hoz", "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", @@ -875,8 +898,8 @@ "no_assets_message": "KATTINTSON AZ ELSŐ FÉNYKÉPE FELTÖLTÉSÉHEZ", "no_duplicates_found": "Duplikátumok nem találhatók.", "no_exif_info_available": "Exif információ nem elérhető", - "no_explore_results_message": "Töltsön fel több fényképet, hogy felfedezze a gyűjteményét.", - "no_favorites_message": "Jelöljön meg kedvenceket, hogy gyorsan megtalálhassa legjobb fényképeit és videóit", + "no_explore_results_message": "Töltsön fel több fényképet, hogy böngészhesse a gyűjteményét.", + "no_favorites_message": "Hozzáadás a kedvencekhez, hogy hamarabb megtalálhassa a legjobb fényképeit és videóit", "no_libraries_message": "Hozzon létre külső képtárat a fényképei és videói megtekintéséhez", "no_name": "Nincs Név", "no_places": "Nincsenek helyek", @@ -939,6 +962,7 @@ "pending": "Folyamatban lévő", "people": "Személyek", "people_edits_count": "{count, plural, other {# személy}} szerkesztve", + "people_feature_description": "Személyek szerint csoportosított fényképek és videók böngészése", "people_sidebar_description": "Jelenítsen meg linket a Személyek fülhöz oldalt", "perform_library_tasks": "", "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", @@ -1009,7 +1033,9 @@ "purchase_settings_server_activated": "A szerver termékkulcsot az admin menedzseli", "range": "", "rating": "Értékelés csillagokkal", - "rating_description": "Exif értékelés megjelenítése az infópanelben", + "rating_clear": "Értékelés törlése", + "rating_count": "{count, plural, one {# csillag} other {# csillagok}}", + "rating_description": "Exif értékelés megjelenítése az infópanelen", "raw": "", "reaction_options": "Reakció lehetőségek", "read_changelog": "Változtatások olvasása", @@ -1042,6 +1068,7 @@ "removed_from_archive": "Archívumból eltávolítva", "removed_from_favorites": "Kedvencekből eltávolítva", "removed_from_favorites_count": "A kedvencekből el lett távolítva {count, plural, other {# elem}}", + "removed_tagged_assets": "Címke eltávolítva az {count, plural, one {# elemről} other {# elemekről}}", "rename": "Átnevezés", "repair": "Javítás", "repair_no_results_message": "Nem megfigyelt és hiányzó fájlok itt jelennek meg", @@ -1074,7 +1101,8 @@ "scan_all_libraries": "Minden könyvtár átnézése", "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", - "scan_settings": "Felfedezési beállítások", + "scan_settings": "Szkennelési beállítások", + "scanning_for_album": "Album szkennelése...", "search": "Keresés", "search_albums": "Albumok keresése", "search_by_context": "Keresés kontextus alapján", @@ -1087,13 +1115,16 @@ "search_for_existing_person": "Már meglévő személy keresése", "search_no_people": "Nincs személy", "search_no_people_named": "Nincs személy \"{name}\" néven", + "search_options": "Keresési lehetőségek", "search_people": "Személyek keresése", "search_places": "Helyek keresése", + "search_settings": "Keresési beállítások", "search_state": "Régió keresése...", + "search_tags": "Címkék keresése...", "search_timezone": "Időzóna keresése...", "search_type": "Típus keresése", "search_your_photos": "Fotók keresése", - "searching_locales": "", + "searching_locales": "Helyszín keresése...", "second": "Másodperc", "see_all_people": "Minden személy megtekintése", "select_album_cover": "Albumborító kiválasztása", @@ -1107,7 +1138,7 @@ "select_library_owner": "Könyvtártulajdonos kijelölése", "select_new_face": "Új arc kiválasztása", "select_photos": "Fotók választása", - "select_trash_all": "Minden szemétbe helyezése", + "select_trash_all": "Minden lomtárba helyezése", "selected": "Kijelölt", "selected_count": "{count, plural, other {# kiválasztva}}", "send_message": "Üzenet küldése", @@ -1127,7 +1158,7 @@ "settings_saved": "Beállítások mentve", "share": "Megosztás", "shared": "Megosztva", - "shared_by": "Megosztva általa:", + "shared_by": "Megosztotta", "shared_by_user": "Megosztva {user} által", "shared_by_you": "Megosztva Ön által", "shared_from_partner": "Fényképek {partner}-tól/től", @@ -1158,10 +1189,14 @@ "show_supporter_badge": "Támogató jelvény", "show_supporter_badge_description": "Támogató jelvény megjelenítése", "shuffle": "Keverés", + "sidebar": "Oldalsáv", + "sidebar_display_description": "Nézetre mutató link megjelenítése az oldalsávban", "sign_out": "Kilépés", "sign_up": "Feliratkozás", "size": "Méret", "skip_to_content": "Ugrás a tartalomhoz", + "skip_to_folders": "Ugrás a mappákra", + "skip_to_tags": "Ugrás a címkékhez", "slideshow": "Diavetítés", "slideshow_settings": "Diavetítés beállításai", "sort_albums_by": "Albumok rendezése...", @@ -1194,6 +1229,14 @@ "sunrise_on_the_beach": "Napkelte a tengerparton", "swap_merge_direction": "Egyesítés irányának megfordítása", "sync": "Szinkronizálás", + "tag": "Címke", + "tag_assets": "Elemek címkézése", + "tag_created": "Létrehozott címke: {tag}", + "tag_feature_description": "Címkék szerinti fényképek és videók böngészése", + "tag_not_found_question": "Nem találja a címkét? Hozzon létre egyet itt", + "tag_updated": "Frissített címke: {tag}", + "tagged_assets": "Címkézett {count, plural, one {# elem} other {# elemek}}", + "tags": "Címkék", "template": "Minta", "theme": "Téma", "theme_selection": "Témaválasztás", @@ -1205,17 +1248,18 @@ "to_change_password": "Jelszó megváltoztatása", "to_favorite": "Kedvenc", "to_login": "Bejelentkezés", - "to_trash": "Szemétbe helyezés", + "to_parent": "Egy szinttel feljebb", + "to_trash": "Lomtárba helyezés", "toggle_settings": "Beállítások változtatása", - "toggle_theme": "Témaváltás", + "toggle_theme": "Sötét téma váltása", "toggle_visibility": "Láthatóság változtatása", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", - "trash_count": "{count, number} elem szemétbe helyezése", - "trash_delete_asset": "Elem szemétbe helyezése / törlése", - "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videok.", - "trashed_items_will_be_permanently_deleted_after": "A szemeteskosárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "trash_count": "{count, number} elem lomtárba helyezése", + "trash_delete_asset": "Lomtárba helyezés/törlés", + "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videók.", + "trashed_items_will_be_permanently_deleted_after": "A lomtárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", "type": "Típus", "unarchive": "Archívumból kivétel", "unarchived": "Archívumból kivett", @@ -1226,9 +1270,11 @@ "unknown_album": "Ismeretlen Album", "unknown_year": "Ismeretlen év", "unlimited": "Korlátlan", + "unlink_motion_video": "Mozgókép leválasztása", "unlink_oauth": "OAuth leválasztása", "unlinked_oauth_account": "Leválasztott OAuth felhasználó", "unnamed_album": "Névtelen Album", + "unnamed_album_delete_confirmation": "Biztosan törölni szeretné ezt az albumot?", "unnamed_share": "Névtelen Megosztás", "unsaved_change": "Mentés nélküli változtatás", "unselect_all": "Összes kiválasztás törlése", @@ -1240,7 +1286,7 @@ "up_next": "Következik", "updated_password": "Jelszó megváltoztatva", "upload": "Feltöltés", - "upload_concurrency": "", + "upload_concurrency": "Párhuzamos feltöltés", "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", "upload_skipped_duplicates": "{count, plural, other {# megegyező elem}} kihagyva", @@ -1249,7 +1295,7 @@ "upload_status_uploaded": "Feltöltve", "upload_success": "Feltöltés sikeres, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "url": "URL", - "usage": "Felhasználás", + "usage": "Használat", "use_custom_date_range": "Szabadon megadott időintervallum használata", "user": "Felhasználó", "user_id": "Felhasználó azonosítója", @@ -1264,6 +1310,8 @@ "validate": "Ellenőrzés", "variables": "Változók", "version": "Verzió", + "version_announcement_closing": "Barátod, Alex", + "version_announcement_message": "Szia barátom, van egy új verziója az alkalmazásnak. Kérjük, szánj időt a verzióinformáció megtekintésére, és győződj meg róla, hogy a docker-compose.yml és a .env beállítások naprakészek, hogy elkerüld a hibás konfigurációt, különösen, ha WatchTower-t vagy valami más automatikus frissítési megoldást használsz.", "video": "Videó", "video_hover_setting": "Bélyegkép felett lebegésnél videó indítás", "video_hover_setting_description": "Ha az egér a bélyegkép felett időzik, a bélyegkép videó lejátszása induljon el. A lejátszás az indítás ikon feletti időzéssel akkor is elindul, ha ez az opció ki van kapcsolva.", @@ -1273,6 +1321,7 @@ "view_album": "Album megtekintése", "view_all": "Összes mutatása", "view_all_users": "Minden felhasználó megtekintése", + "view_in_timeline": "Megtekintés az idővonalon", "view_links": "Linkek megtekintése", "view_next_asset": "Következő elem megtekintése", "view_previous_asset": "Előző elem megtekintése", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index 1321bd358b..99df952c21 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -41,6 +41,7 @@ "confirm_email_below": "Untuk mengonfirmasi, ketik \"{email}\" di bawah", "confirm_reprocess_all_faces": "Apakah Anda yakin ingin memproses semua wajah? Ini juga akan menghapus nama orang.", "confirm_user_password_reset": "Apakah Anda yakin ingin mengatur ulang kata sandi {user}?", + "create_job": "Buat tugas", "disable_login": "Nonaktifkan log masuk", "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mendeteksi gambar yang serupa. Bergantung pada Pencarian Pintar", "exclusion_pattern_description": "Pola pengecualian memungkinkan Anda mengabaikan berkas dan folder ketika memindai pustaka Anda. Ini berguna jika Anda memiliki folder yang berisi berkas yang tidak ingin diimpor, seperti berkas RAW.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Resolusi gambar kecil", "image_thumbnail_resolution_description": "Digunakan ketika menampilkan kelompok foto (lini masa utama, tampilan album, dll.). Resolusi yang lebih tinggi dapat menjaga lebih banyak detail tetapi memerlukan waktu lama untuk mengode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", "job_concurrency": "Konkurensi {job}", + "job_created": "Tugas telah dibuat", "job_not_concurrency_safe": "Tugas ini tidak aman untuk konkurensi.", "job_settings": "Pengaturan Tugas", "job_settings_description": "Kelola konkurensi tugas", @@ -196,6 +198,7 @@ "password_settings": "Log Masuk Kata Sandi", "password_settings_description": "Kelola pengaturan log masuk kata sandi", "paths_validated_successfully": "Semua jalur berhasil divalidasi", + "person_cleanup_job": "Pembersihan data pribadi", "quota_size_gib": "Ukuran Kuota (GiB)", "refreshing_all_libraries": "Menyegarkan semua pustaka", "registration": "Pendaftaran Admin", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Atur ulang pengaturan ke pengaturan tersimpan terkini", "scanning_library_for_changed_files": "Memindai pustaka untuk berkas yang telah diubah", "scanning_library_for_new_files": "Memindai pustaka untuk berkas baru", + "search_jobs": "Mencari tugas...", "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Kelola struktur folder dan nama berkas dari aset yang diunggah", "storage_template_user_label": "{label} adalah Label Penyimpanan pengguna", "system_settings": "Pengaturan Sistem", + "tag_cleanup_job": "Pembersihan tag", "theme_custom_css_settings": "CSS Kustom", "theme_custom_css_settings_description": "CSS memungkinkan desain Immich untuk diubah.", "theme_settings": "Pengaturan Tema", @@ -309,6 +314,7 @@ "trash_settings_description": "Kelola pengaturan sampah", "untracked_files": "Berkas yang Belum Dilacak", "untracked_files_description": "Berkas ini tidak dilacak oleh aplikasi. Mereka dapat diakibatkan oleh pemindahan gagal, pengunggahan terganggu, atau tertinggal karena oleh kutu", + "user_cleanup_job": "Pembersihan data pengguna", "user_delete_delay": "Akun dan aset {user} akan dijadwalkan untuk penghapusan permanen dalam {delay, plural, one {# hari} other {# hari}}.", "user_delete_delay_settings": "Jeda penghapusan", "user_delete_delay_settings_description": "Jumlah hari setelah penghapusan untuk menghapus akun dan aset pengguna secara permanen. Tugas penghapusan pengguna berjalan pada tengah malam untuk memeriksa pengguna yang siap untuk dihapus. Perubahan pengaturan ini akan dievaluasi pada eksekusi berikutnya.", @@ -1111,6 +1117,7 @@ "search_options": "Pilihan pencarian", "search_people": "Cari orang", "search_places": "Cari tempat", + "search_settings": "Pengaturan pencarian", "search_state": "Cari negara bagian...", "search_tags": "Cari tag...", "search_timezone": "Cari zona waktu...", diff --git a/web/src/lib/i18n/lv.json b/web/src/lib/i18n/lv.json index 2701cda4e8..8f0835397d 100644 --- a/web/src/lib/i18n/lv.json +++ b/web/src/lib/i18n/lv.json @@ -129,7 +129,7 @@ "notification_email_test_email_sent": "", "notification_email_username_description": "", "notification_enable_email_notifications": "", - "notification_settings": "", + "notification_settings": "Paziņojumu iestatījumi", "notification_settings_description": "", "oauth_auto_launch": "", "oauth_auto_launch_description": "", @@ -279,19 +279,22 @@ "archive_or_unarchive_photo": "", "archive_size": "Arhīva izmērs", "archived": "", + "are_these_the_same_person": "Vai šī ir tā pati persona?", "asset_offline": "", "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "back": "Atpakaļ", "backward": "", + "birthdate_saved": "Dzimšanas datums veiksmīgi saglabāts", + "birthdate_set_description": "Dzimšanas datums tiek izmantots, lai aprēķinātu šīs personas vecumu fotogrāfijas uzņemšanas brīdī.", "blurred_background": "", "camera": "", "camera_brand": "", "camera_model": "", "cancel": "Atcelt", "cancel_search": "", - "cannot_merge_people": "", + "cannot_merge_people": "Nevar apvienot cilvēkus", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", @@ -301,17 +304,18 @@ "change_expiration_time": "Izmainīt derīguma termiņu", "change_location": "", "change_name": "", - "change_name_successfully": "", - "change_password": "Nomainīt Paroli", + "change_name_successfully": "Vārds veiksmīgi nomainīts", + "change_password": "Nomainīt paroli", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", + "choose_matching_people_to_merge": "Izvēlies atbilstošus cilvēkus apvienošanai", "city": "Pilsēta", "clear": "Notīrīt", "clear_all": "", "clear_message": "", "clear_value": "", - "close": "", + "close": "Aizvērt", "collapse_all": "", "color_theme": "", "comment_options": "", @@ -349,6 +353,7 @@ "date_after": "", "date_and_time": "Datums un Laiks", "date_before": "", + "date_of_birth_saved": "Dzimšanas datums veiksmīgi saglabāts", "date_range": "Datumu diapazons", "day": "", "default_locale": "", @@ -401,6 +406,8 @@ "edit_user": "Labot lietotāju", "edited": "", "editor": "", + "editor_close_without_save_prompt": "Izmaiņas netiks saglabātas", + "editor_close_without_save_title": "Aizvērt redaktoru?", "email": "E-pasts", "empty": "", "empty_album": "", @@ -411,6 +418,7 @@ "error": "", "error_loading_image": "", "errors": { + "cant_search_people": "Neizdevās veikt peronu meklēšanu", "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", @@ -429,7 +437,7 @@ "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", + "unable_to_hide_person": "Neizdevās paslēpt personu", "unable_to_load_album": "", "unable_to_load_asset_activity": "", "unable_to_load_items": "", @@ -449,6 +457,7 @@ "unable_to_restore_trash": "", "unable_to_restore_user": "", "unable_to_save_album": "", + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", "unable_to_save_name": "", "unable_to_save_profile": "", "unable_to_save_settings": "", @@ -500,50 +509,57 @@ "group_albums_by": "", "has_quota": "Ir kvota", "hide_gallery": "", + "hide_named_person": "Paslēpt personu {name}", "hide_password": "", - "hide_person": "", + "hide_person": "Paslēpt personu", "host": "", "hour": "", "image": "Attēls", "img": "", - "immich_logo": "", - "import_path": "", - "in_archive": "", + "immich_logo": "Immich logo", + "import_from_json": "Importēt no JSON", + "import_path": "Importa ceļš", + "in_albums": "{count, plural, one {# albumā} other {# albumos}}", + "in_archive": "Arhīvā", "include_archived": "Iekļaut arhivētos", - "include_shared_albums": "", + "include_shared_albums": "Iekļaut koplietotos albumus", "include_shared_partner_assets": "", "individual_share": "", - "info": "", + "info": "Informācija", "interval": { - "day_at_onepm": "", + "day_at_onepm": "Katru dienu 13.00", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "Katru dienu pusnaktī", + "night_at_twoam": "Katru dienu 2.00 naktī" }, - "invite_people": "", + "invite_people": "Ielūgt cilvēkus", "invite_to_album": "Uzaicināt albumā", "job_settings_description": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", + "jobs": "Darbi", + "keep": "Paturēt", + "keep_all": "Paturēt visus", + "keyboard_shortcuts": "Tastatūras saīsnes", + "language": "Valoda", + "language_setting_description": "Izvēlieties vēlamo valodu", + "last_seen": "Pēdējo reizi redzēts", + "latest_version": "Jaunākā versija", + "latitude": "Ģeogrāfiskais platums", + "leave": "Paturēt", "let_others_respond": "Ļaut citiem atbildēt", - "level": "", + "level": "Līmenis", "library": "Bibliotēka", "library_options": "", "light": "", "link_options": "", "link_to_oauth": "", "linked_oauth_account": "", - "list": "", - "loading": "", + "list": "Saraksts", + "loading": "Ielādē", "loading_search_results_failed": "", "log_out": "Izrakstīties", "log_out_all_devices": "", "login_has_been_disabled": "", + "longitude": "Ģeogrāfiskais garums", "look": "", "loop_videos": "", "loop_videos_description": "Iespējot, lai automātiski videoklips tiktu cikliski palaists detaļu skatītājā.", @@ -559,46 +575,53 @@ "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", - "media_type": "", - "memories": "", + "matches": "Atbilstības", + "media_type": "Multivides veids", + "memories": "Atmiņas", "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", + "memory": "Atmiņa", + "menu": "Izvēlne", + "merge": "Apvienot", + "merge_people": "Cilvēku apvienošana", + "merge_people_limit": "Vienlaikus var apvienot ne vairāk kā 5 sejas", + "merge_people_prompt": "Vai vēlies apvienot šos cilvēkus? Šī darbība ir neatgriezeniska.", + "merge_people_successfully": "Cilvēki veiksmīgi apvienoti", + "minimize": "Minimizēt", + "minute": "Minūte", + "missing": "Trūkstošie", "model": "Modelis", "month": "Mēnesis", - "more": "", + "more": "Vairāk", "moved_to_trash": "", - "my_albums": "", + "my_albums": "Mani albumi", "name": "Vārds", - "name_or_nickname": "", + "name_or_nickname": "Vārds vai iesauka", "never": "nekad", - "new_api_key": "", + "new_album": "Jauns albums", + "new_api_key": "Jauna API atslēga", "new_password": "Jaunā parole", - "new_person": "", + "new_person": "Jauna persona", "new_user_created": "Izveidots jauns lietotājs", + "new_version_available": "PIEEJAMA JAUNA VERSIJA", "newest_first": "", "next": "Nākošais", - "next_memory": "", - "no": "", + "next_memory": "Nākamā atmiņa", + "no": "Nē", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "NOKLIKŠĶINIET, LAI AUGŠUPIELĀDĒTU SAVU PIRMO FOTOATTĒLU", "no_duplicates_found": "Dublikāti netika atrasti.", - "no_exif_info_available": "", + "no_exif_info_available": "Nav pieejama exif informācija", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "Nav nosaukuma", + "no_places": "Nav atrašanās vietu", + "no_results": "Nav rezultātu", + "no_results_description": "Izmēģiniet sinonīmu vai vispārīgāku atslēgvārdu", "no_shared_albums_message": "", - "not_in_any_album": "", - "notes": "", + "not_in_any_album": "Nav nevienā albumā", + "notes": "Piezīmes", "notification_toggle_setting_description": "", "notifications": "Paziņojumi", "notifications_setting_description": "", @@ -707,7 +730,9 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", + "search_no_people": "Nav cilvēku", + "search_no_people_named": "Nav cilvēku ar vārdu \"{name}\"", + "search_people": "Meklēt cilvēkus", "search_places": "", "search_state": "", "search_timezone": "", @@ -732,7 +757,7 @@ "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", + "set_date_of_birth": "Iestatīt dzimšanas datumu", "set_profile_picture": "", "set_slideshow_to_fullscreen": "", "settings": "Iestatījumi", @@ -783,6 +808,7 @@ "theme": "Dizains", "theme_selection": "", "theme_selection_description": "", + "they_will_be_merged_together": "Tās tiks apvienotas", "time_based_memories": "", "timezone": "Laika zona", "toggle_settings": "", @@ -795,8 +821,8 @@ "type": "", "unarchive": "Atarhivēt", "unarchived": "", - "unfavorite": "Noņemt no Izlases", - "unhide_person": "", + "unfavorite": "Noņemt no izlases", + "unhide_person": "Atcelt personas slēpšanu", "unknown": "", "unknown_album": "", "unknown_year": "", @@ -836,6 +862,7 @@ "week": "", "welcome_to_immich": "", "year": "", + "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" } diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index dc9f003978..23dfa7633d 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Typ hieronder \"{email}\" ter bevestiging", "confirm_reprocess_all_faces": "Weet je zeker dat je alle gezichten opnieuw wilt verwerken? Hiermee worden ook alle mensen gewist.", "confirm_user_password_reset": "Weet u zeker dat je het wachtwoord van {user} wilt resetten?", + "create_job": "Taak maken", "crontab_guru": "Crontab Guru", "disable_login": "Inloggen uitschakelen", "disabled": "Uitgeschakeld", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Thumbnail resolutie", "image_thumbnail_resolution_description": "Gebruikt wanneer groepen foto's bekeken worden (hoofdtijdslijn, album, enzo). Hogere resoluties kunnen meer detail behouden maar duren langer om te verwerken, hebben hogere bestandsgrootte, en kunnen de applicatie langzamer maken.", "job_concurrency": "{job} gelijktijdigheid", + "job_created": "Taak aangemaakt", "job_not_concurrency_safe": "Deze taak kan niet gelijktijdig worden uitgevoerd.", "job_settings": "Achtergrondtaak-instellingen", "job_settings_description": "Beheer gelijktijdige taken", @@ -211,6 +213,7 @@ "reset_settings_to_recent_saved": "Instellingen zijn gereset naar de recent opgeslagen instellingen", "scanning_library_for_changed_files": "Bibliotheek scannen op gewijzigde bestanden", "scanning_library_for_new_files": "Bibliotheek scannen op nieuwe bestanden", + "search_jobs": "Taak zoeken...", "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", @@ -661,6 +664,7 @@ "unable_to_get_comments_number": "Kan het aantal opmerkingen niet ophalen", "unable_to_get_shared_link": "Kan gedeelde link niet ophalen", "unable_to_hide_person": "Kan persoon niet verbergen", + "unable_to_link_motion_video": "Kan bewegende video niet verbinden", "unable_to_link_oauth_account": "Kan OAuth account niet koppelen", "unable_to_load_album": "Kan album niet laden", "unable_to_load_asset_activity": "Kan asset activiteit niet laden", @@ -701,6 +705,7 @@ "unable_to_submit_job": "Kan taak niet uitvoeren", "unable_to_trash_asset": "Kan asset niet naar prullenbak verplaatsen", "unable_to_unlink_account": "Kan account niet ontkoppelen", + "unable_to_unlink_motion_video": "Kan bewegende video niet los maken", "unable_to_update_album_cover": "Kan album cover niet bijwerken", "unable_to_update_album_info": "Kan albumgegevens niet bijwerken", "unable_to_update_library": "Kan bibliotheek niet bijwerken", @@ -846,6 +851,7 @@ "license_trial_info_4": "Overweeg een licentie te kopen om de verdere ontwikkeling van de service te ondersteunen", "light": "Licht", "like_deleted": "Like verwijderd", + "link_motion_video": "verbind bewegende video", "link_options": "Opties voor link", "link_to_oauth": "Koppel OAuth", "linked_oauth_account": "Gekoppeld OAuth account", @@ -1139,6 +1145,7 @@ "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", + "search_settings": "Zoek instellingen", "search_state": "Zoek staat...", "search_tags": "Tags zoeken...", "search_timezone": "Zoek tijdzone...", @@ -1291,6 +1298,7 @@ "unknown_album": "Onbekend album", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", + "unlink_motion_video": "Maak bewegende video los", "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index ebe1e85729..5c20ffb81a 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -1,11 +1,11 @@ { "about": "Sobre", "account": "Conta", - "account_settings": "Configurações da Conta", + "account_settings": "Definições da Conta", "acknowledge": "Confirmar", "action": "Ação", "actions": "Ações", - "active": "Ativo", + "active": "Em execução", "activity": "Atividade", "activity_changed": "A actividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", @@ -22,338 +22,347 @@ "add_photos": "Adicionar fotos", "add_to": "Adicionar a...", "add_to_album": "Adicionar ao álbum", - "add_to_shared_album": "Adicionar ao álbum compartilhado", + "add_to_shared_album": "Adicionar ao álbum partilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { - "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", - "authentication_settings": "Configurações de Autenticação", - "authentication_settings_description": "Gerenciar senhas, OAuth, e outras configurações de autenticação", - "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de entrada? Entrar será completamente desativado.", + "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os ficheiros em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os ficheiros que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", + "authentication_settings": "Definições de Autenticação", + "authentication_settings_description": "Gerir palavras-passe, OAuth, e outras definições de autenticação", + "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de início de sessão? O início de sessão será completamente desativado.", "authentication_settings_reenable": "Para reativar, use um Comando de servidor.", "background_task_job": "Tarefas em segundo plano", "check_all": "Selecionar Tudo", "cleared_jobs": "Eliminadas as tarefas de: {job}", - "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", - "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", - "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# arquivo incluído} other {todos os # arquivos incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", - "confirm_email_below": "Para confirmar, digite o {email} abaixo", - "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos as faces? Isso também limpará as pessoas nomeadas.", - "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", + "config_set_by_file": "A configuração está atualmente definida por um ficheiro de configuração", + "confirm_delete_library": "Tem a certeza de que deseja eliminar a biblioteca {library} ?", + "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# ficheiro incluído} other {todos os # ficheiros incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", + "confirm_email_below": "Para confirmar, escreva \"{email}\" abaixo", + "confirm_reprocess_all_faces": "Tem a certeza de que deseja reprocessar todos os rostos? Isto também limpará os nomes de pessoas.", + "confirm_user_password_reset": "Tem a certeza de que deseja redefinir a palavra-passe de {user}?", + "create_job": "Criar tarefa", "crontab_guru": "Guru do Crontab", - "disable_login": "Desabilitar login", + "disable_login": "Desativar inicio de sessão", "disabled": "", - "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da pesquisa inteligente", - "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", + "duplicate_detection_job_description": "Executa a aprendizagem de máquina em ficheiros para detetar imagens semelhantes. Depende da Pesquisa Inteligente", + "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar ficheiros e pastas ao analisar a sua biblioteca. Isto é útil se tiver pastas que contenham ficheiros que não deseja importar, como ficheiros RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", - "external_library_management": "Gerenciamento de bibliotecas externas", - "face_detection": "Detecção de faces", - "face_detection_description": "Deteta rostos em arquivos com aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os arquivos. \"Ausente\" enfileira arquivos que ainda não foram processados. Os rostos detetados serão enfileirados para reconhecimento facial após a conclusão da deteção de rostos, agrupando-os em pessoas novas ou já existentes.", - "facial_recognition_job_description": "Agrupa rostos detectados em pessoas. Esta etapa é executada após a conclusão da deteção de faces. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira rostos que ainda não têm uma pessoa atribuída.", + "external_library_management": "Gestão de bibliotecas externas", + "face_detection": "Deteção de Rostos", + "face_detection_description": "Deteta rostos em ficheiros utilizando aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os ficheiros. \"Ausente\" coloca em fila ficheiros que ainda não foram processados. Os rostos detetados serão colocados em fila para Reconhecimento Facial após a conclusão da Deteção de Rostos, agrupando-os em pessoas novas ou já existentes.", + "facial_recognition_job_description": "Agrupa rostos detetadas em pessoas. Esta etapa é executada após a conclusão da Deteção de Rostos. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" coloca em fila rostos que ainda não têm uma pessoa atribuída.", "failed_job_command": "Comando {command} falhou para a tarefa: {job}", - "force_delete_user_warning": "AVISO: Isso removerá imediatamente o utilizador e todos os arquivos. Isso não pode ser desfeito e os ficheiros não poderão ser recuperados.", - "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", - "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", - "image_prefer_embedded_preview": "Prefira visualização incorporada", - "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", + "force_delete_user_warning": "AVISO: Isto removerá imediatamente o utilizador e todos os ficheiros. Isso não pode ser revertido e os ficheiros não poderão ser recuperados.", + "forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca", + "image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.", + "image_prefer_embedded_preview": "Preferir visualização incorporada", + "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", "image_prefer_wide_gamut": "Prefira ampla gama", - "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", + "image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", "image_preview_format": "Formato de visualização", "image_preview_resolution": "Resolução de visualização", - "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizado de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizagem de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "image_quality": "Qualidade", - "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz arquivos maiores. Esta opção afeta as imagens de visualização e miniatura.", - "image_settings": "Configurações de imagem", - "image_settings_description": "Gerenciar a qualidade e resolução das imagens geradas", + "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz ficheiros maiores. Esta definição afeta as imagens de visualização e miniatura.", + "image_settings": "Definições de imagem", + "image_settings_description": "Gerir a qualidade e resolução das imagens geradas", "image_thumbnail_format": "Formato de miniatura", "image_thumbnail_resolution": "Resolução de miniatura", - "image_thumbnail_resolution_description": "Usado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", - "job_concurrency": "{job} simultâneo", - "job_not_concurrency_safe": "Este trabalho não é compatível com simultaneidade.", - "job_settings": "Configurações de trabalho", - "job_settings_description": "Gerenciar simultaneidade dos trabalhos", - "job_status": "Status do trabalho", + "image_thumbnail_resolution_description": "Utilizado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "job_concurrency": "{job} em simultâneo", + "job_created": "Tarefa criada", + "job_not_concurrency_safe": "Esta tarefa não pode ser executada em simultâneo.", + "job_settings": "Definições de Tarefas", + "job_settings_description": "Gerir tarefas em simultâneo", + "job_status": "Estado das Tarefas", "jobs_delayed": "{jobCount, plural, one {# adiado} other {# adiados}}", "jobs_failed": "{jobCount, plural, one {# falhou} other {# falharam}}", - "library_created": "Criado biblioteca: {library}", + "library_created": "Criada biblioteca: {library}", "library_cron_expression": "Expressão Cron", "library_cron_expression_description": "Defina o intervalo de procura utilizando o formato cron. Para mais informações consulte Guru Crontab", "library_cron_expression_presets": "Predefinições de expressão Cron", - "library_deleted": "Biblioteca excluída", - "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo subpastas, será escaneada em busca de imagens e vídeos.", - "library_scanning": "Escanear periódicamente", - "library_scanning_description": "Configurar o escaneamento periódico da biblioteca", - "library_scanning_enable_description": "Habilitar escaneamento periódico da biblioteca", + "library_deleted": "Biblioteca eliminada", + "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo sub-pastas, será analisada por imagens e vídeos.", + "library_scanning": "Análise periódica", + "library_scanning_description": "Configurar a análise periódica da biblioteca", + "library_scanning_enable_description": "Ativar análise periódica da biblioteca", "library_settings": "Biblioteca Externa", - "library_settings_description": "Gerenciar configurações de biblioteca externa", - "library_tasks_description": "Execute tarefas de biblioteca", - "library_watching_enable_description": "Observe bibliotecas externas para alterações de arquivos", - "library_watching_settings": "Observação de biblioteca (EXPERIMENTAL)", - "library_watching_settings_description": "Observe automaticamente os arquivos alterados", - "logging_enable_description": "Habilitar registro", - "logging_level_description": "Quando ativado, qual nível de log usar.", - "logging_settings": "Registros", + "library_settings_description": "Gerir definições de biblioteca externa", + "library_tasks_description": "Executa tarefas de biblioteca", + "library_watching_enable_description": "Analisar bibliotecas externas por alterações de ficheiros", + "library_watching_settings": "Análise de biblioteca (EXPERIMENTAL)", + "library_watching_settings_description": "Analise automaticamente por ficheiros alterados", + "logging_enable_description": "Ativar registo", + "logging_level_description": "Quando ativado, qual o nível de log a usar.", + "logging_settings": "Registo", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Note que é necessário voltar a executar a \"Pesquisa Inteligente\" para todas as imagens depois de alterar um modelo.", - "machine_learning_duplicate_detection": "Detecção de duplicidade", - "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", - "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", - "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", - "machine_learning_enabled": "Habilitar o aprendizado da máquina", - "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", + "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Tome nota de que é necessário voltar a executar a tarefa de \"Pesquisa Inteligente\" para todas as imagens depois de alterar o modelo.", + "machine_learning_duplicate_detection": "Deteção de Itens Duplicados", + "machine_learning_duplicate_detection_enabled": "Ativar deteção de itens duplicados", + "machine_learning_duplicate_detection_enabled_description": "Se desativado, ficheiros exatamente idênticos serão desduplicados na mesma.", + "machine_learning_duplicate_detection_setting_description": "Utilizar embeddings CLIP para encontrar itens provavelmente duplicados", + "machine_learning_enabled": "Ativar a aprendizagem de máquina", + "machine_learning_enabled_description": "Se desativado, todos as funcionalidades de ML serão desativados, independentemente das definições abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", - "machine_learning_facial_recognition_description": "Deteta, reconhece e agrupa rostos em imagens", + "machine_learning_facial_recognition_description": "Detetar, reconhecer e agrupar rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", - "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente o trabalho de Detecção de faces para todas as imagens.", + "machine_learning_facial_recognition_model_description": "Os modelos estão ordenados por ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Tome conta de que ao alterar um modelo, deve executar novamente a tarefa de \"Deteção de Rostos\" para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", - "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a seção Pessoas na página Explorar.", - "machine_learning_max_detection_distance": "Distância máxima de detecção", - "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detectarão mais duplicidades, mas poderão resultar em falsos positivos.", + "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a secção Pessoas na página Explorar.", + "machine_learning_max_detection_distance": "Distância máxima de deteção", + "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detetarão mais duplicidades, mas poderão resultar em falsos positivos.", "machine_learning_max_recognition_distance": "Distância máxima de reconhecimento", - "machine_learning_max_recognition_distance_description": "Distância máxima entre duas faces para ser considerada a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular duas faces como a mesma pessoa, enquanto valores maiores evitam rotular a mesma face como duas pessoas diferentes. Observe que é mais fácil mesclar duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", - "machine_learning_min_detection_score": "Pontuação mínima de detecção", - "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para uma face ser detectada, de 0 a 1. Valores mais baixos detectam mais rostos, mas poderão resultar em falsos positivos.", - "machine_learning_min_recognized_faces": "Mínimo de faces reconhecidas", - "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", - "machine_learning_settings": "Configurações de aprendizado de máquina (Machine Learning)", - "machine_learning_settings_description": "Gerenciar recursos e configurações de aprendizado de máquina", - "machine_learning_smart_search": "Busca inteligente", - "machine_learning_smart_search_description": "Pesquise imagens semanticamente usando embeddings CLIP", - "machine_learning_smart_search_enabled": "Habilite a pesquisa inteligente", - "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "URL do servidor de aprendizado de máquina", - "manage_concurrency": "Gerenciar simultaneidade", - "manage_log_settings": "Gerenciar configurações de registro", + "machine_learning_max_recognition_distance_description": "Distância máxima entre dois rostos para serem considerados a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular dois rostos como a mesma pessoa, enquanto valores maiores evitam rotular o mesmo rosto como duas pessoas diferentes. Tenha em conta de que é mais fácil unir duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", + "machine_learning_min_detection_score": "Pontuação mínima de deteção", + "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detetado, de 0 a 1. Valores mais baixos detetam mais rostos, mas poderão resultar em falsos positivos.", + "machine_learning_min_recognized_faces": "Mínimo de rostos reconhecidos", + "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isto torna o Reconhecimento Facial mais preciso, no entanto aumenta a probabilidade de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Definições de aprendizagem de máquina (Machine Learning)", + "machine_learning_settings_description": "Gerir funcionalidades e definições de aprendizagem de máquina", + "machine_learning_smart_search": "Pesquisa Inteligente", + "machine_learning_smart_search_description": "Pesquise imagens semanticamente utilizando embeddings CLIP", + "machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente", + "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.", + "machine_learning_url_description": "URL do servidor de aprendizagem de máquina", + "manage_concurrency": "Gerir simultaneidade", + "manage_log_settings": "Gerir definições de registo", "map_dark_style": "Tema Escuro", - "map_enable_description": "Ativar recursos do mapa", + "map_enable_description": "Ativar funcionalidades de mapa", "map_gps_settings": "Mapas e Definições de GPS", - "map_gps_settings_description": "Configurações de mapas e GPS (Geocoding inverso)", - "map_implications": "A funcionalidade do mapa necessita um servico externo (tiles.immich.cloud)", + "map_gps_settings_description": "Gerir Definições de Mapas e GPS (Geocodificação Reversa)", + "map_implications": "A funcionalidades do mapa necessita um serviço externo (tiles.immich.cloud)", "map_light_style": "Tema Claro", - "map_manage_reverse_geocoding_settings": "Gerir definições de Geocoding inverso", - "map_reverse_geocoding": "Geocodificação reversa", - "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", - "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", + "map_manage_reverse_geocoding_settings": "Gerir definições de Geocodificação Reversa", + "map_reverse_geocoding": "Geocodificação Reversa", + "map_reverse_geocoding_enable_description": "Ativar Geocodificação Reversa", + "map_reverse_geocoding_settings": "Definições de Geocodificação Reversa", "map_settings": "Mapa", - "map_settings_description": "Gerenciar configurações do mapa", + "map_settings_description": "Gerir definições do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", - "metadata_extraction_job_description": "Extrai informações de metadados de cada ativo, como GPS e resolução", + "metadata_extraction_job_description": "Extrai informações de metadados de cada ficheiro, como GPS, rostos e resolução", "metadata_faces_import_setting": "Ativar a importação facial", + "metadata_faces_import_setting_description": "Importar rostos a partir dos dados EXIF da imagem e ficheiros anexos", + "metadata_settings": "Definições de metadados", + "metadata_settings_description": "Gerir definições de metadados", "migration_job": "Migração", - "migration_job_description": "Migre miniaturas de arquivos e rostos para a estrutura de pastas mais recente", + "migration_job_description": "Migra miniaturas de ficheiros e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", "no_pattern_added": "Nenhum padrão adicionado", - "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", + "note_apply_storage_label_previous_assets": "Observação: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", - "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", + "note_unlimited_quota": "Observação: insira 0 para quota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Servidor de Fotos Immich \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", - "notification_email_password_description": "Senha a ser usada ao autenticar no servidor de e-mail", + "notification_email_password_description": "Palavra-passe a ser usada ao autenticar no servidor de e-mail", "notification_email_port_description": "Porta do servidor de e-mail (por exemplo, 25, 465 ou 587)", - "notification_email_sent_test_email_button": "Envie e-mail de teste e salve", - "notification_email_setting_description": "Configurações para envio de notificações por e-mail", + "notification_email_sent_test_email_button": "Enviar e-mail de teste e gravar", + "notification_email_setting_description": "Definições para envio de notificações por e-mail", "notification_email_test_email": "Enviar e-mail de teste", - "notification_email_test_email_failed": "Falha ao enviar e-mail de teste. Verifique seus valores", - "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", + "notification_email_test_email_failed": "Falha ao enviar e-mail de teste, verifique os valores", + "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique a sua caixa de entrada.", "notification_email_username_description": "Nome de utilizador a ser usado ao autenticar com o servidor de e-mail", - "notification_enable_email_notifications": "Habilitar notificações por e-mail", - "notification_settings": "Configurações de notificação", - "notification_settings_description": "Gerenciar configurações de notificação, incluindo e-mail", - "oauth_auto_launch": "Inicialização automática", - "oauth_auto_launch_description": "Inicie o fluxo de login do OAuth automaticamente ao navegar até a página de login", - "oauth_auto_register": "Registro automático", - "oauth_auto_register_description": "Registre automaticamente novos utilizadores após fazer login com OAuth", - "oauth_button_text": "Botão de texto", + "notification_enable_email_notifications": "Ativar notificações por e-mail", + "notification_settings": "Definições de notificações", + "notification_settings_description": "Gerir definições de notificações, incluindo e-mail", + "oauth_auto_launch": "Arranque automático", + "oauth_auto_launch_description": "Iniciar o fluxo de login do OAuth automaticamente ao navegar até a página de inicio de sessão", + "oauth_auto_register": "Registo automático", + "oauth_auto_register_description": "Registar automaticamente novos utilizadores após iniciarem sessão com o OAuth", + "oauth_button_text": "Texto do botão", "oauth_client_id": "ID do Cliente", "oauth_client_secret": "Segredo do cliente", - "oauth_enable_description": "Faça login com OAuth", + "oauth_enable_description": "Iniciar sessão com o OAuth", "oauth_issuer_url": "URL do emissor", "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", - "oauth_mobile_redirect_uri_override_description": "Ative quando 'app.immich:/' for um URI de redirecionamento inválido.", + "oauth_mobile_redirect_uri_override_description": "Ative quando o provedor do OAuth não permite um URI móvel, como '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de assinatura de perfis", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para assinar o perfil de utilizador.", "oauth_scope": "Escopo", "oauth_settings": "OAuth", - "oauth_settings_description": "Gerenciar configurações de login do OAuth", + "oauth_settings_description": "Gerir definições de inicio de sessão do OAuth", "oauth_settings_more_details": "Para mais informações sobre esta funcionalidade, veja a documentação.", "oauth_signing_algorithm": "Algoritmo de assinatura", - "oauth_storage_label_claim": "Reivindicação de rótulo de armazenamento", - "oauth_storage_label_claim_description": "Defina automaticamente o rótulo de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_claim": "Reivindicação de cota de armazenamento", - "oauth_storage_quota_claim_description": "Defina automaticamente a cota de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_default": "Cota de armazenamento padrão (GiB)", - "oauth_storage_quota_default_description": "Cota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para cota ilimitada).", - "offline_paths": "Caminhos off-line", - "offline_paths_description": "Esses resultados podem ser devidos à exclusão manual de arquivos que não fazem parte de uma biblioteca externa.", - "password_enable_description": "Login com e-mail e senha", - "password_settings": "Senha de acesso", - "password_settings_description": "Gerenciar configurações de login e senha", + "oauth_storage_label_claim": "Reivindicação de Rótulo de Armazenamento", + "oauth_storage_label_claim_description": "Definir automaticamente o Rótulo de Armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_claim": "Reivindicação de quota de armazenamento", + "oauth_storage_quota_claim_description": "Definir automaticamente a quota de armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_default": "Quota de armazenamento padrão (GiB)", + "oauth_storage_quota_default_description": "Quota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para quota ilimitada).", + "offline_paths": "Caminhos Offline", + "offline_paths_description": "Estes resultados podem ser devidos à eliminação manual de ficheiros que não fazem parte de uma biblioteca externa.", + "password_enable_description": "Iniciar sessão com e-mail e palavra-passe", + "password_settings": "Palavra-passe de acesso", + "password_settings_description": "Gerir definições de inicio de sessão e palavra-passe", "paths_validated_successfully": "Todos os caminhos validados com sucesso", - "quota_size_gib": "Tamanho da cota (GiB)", - "refreshing_all_libraries": "Atualizando todas as bibliotecas", - "registration": "Registo de Admin", + "person_cleanup_job": "Limpeza de pessoas", + "quota_size_gib": "Tamanho da quota (GiB)", + "refreshing_all_libraries": "A atualizar todas as bibliotecas", + "registration": "Registo de Administrador", "registration_description": "Como é o primeiro utilizador no sistema, será marcado como administrador, e será responsável pelas tarefas administrativas, sendo que utilizadores adicionais serão criados por si.", - "removing_offline_files": "Removendo arquivos offline", + "removing_offline_files": "A remover ficheiros offline", "repair_all": "Reparar tudo", - "repair_matched_items": "Encontrado {count, plural, one {# item} other {# itens}}", - "repaired_items": "Reparado {count, plural, one {# item} other {# itens}}", - "require_password_change_on_login": "Exigir que o utilizador altere a senha no primeiro início de sessão", - "reset_settings_to_default": "Redefinir as configurações para o padrão", - "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", - "scanning_library_for_changed_files": "Escaneando a biblioteca em busca de arquivos alterados", - "scanning_library_for_new_files": "Escaneando a biblioteca em busca de novos arquivos", + "repair_matched_items": "Encontrado(s) {count, plural, one {# item} other {# itens}}", + "repaired_items": "Reparado(s) {count, plural, one {# item} other {# itens}}", + "require_password_change_on_login": "Exigir que o utilizador altere a palavra-passe no primeiro início de sessão", + "reset_settings_to_default": "Redefinir as definições para o padrão", + "reset_settings_to_recent_saved": "Redefinir as definições para as guardadas mais recentemente", + "scanning_library_for_changed_files": "A analisar a biblioteca por ficheiros alterados", + "scanning_library_for_new_files": "A analisar a biblioteca por ficheiros novos", + "search_jobs": "Pesquisar tarefas...", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", - "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", - "server_settings": "Configurações do servidor", - "server_settings_description": "Gerenciar configurações do servidor", + "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", + "server_settings": "Definições do Servidor", + "server_settings_description": "Gerir definições do servidor", "server_welcome_message": "Mensagem de boas-vindas", - "server_welcome_message_description": "Uma mensagem exibida na página de login.", + "server_welcome_message_description": "Uma mensagem que é exibida na página de inicio de sessão.", "sidecar_job": "Metadados secundários", - "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", + "sidecar_job_description": "Descobrir ou sincronizar metadados secundários a partir do sistema de ficheiros", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", - "storage_template_date_time_description": "O registro de data e hora da criação é usado para fornecer essas informações", + "smart_search_job_description": "Execute a aprendizagem automática em ficheiros para oferecer apoio à Pesquisa Inteligente", + "storage_template_date_time_description": "O registo de data e hora de criação do ficheiro é usado para fornecer essas informações", "storage_template_date_time_sample": "Exemplo de tempo {date}", - "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", + "storage_template_enable_description": "Ativar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", - "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha certeza das implicações", + "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha a certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", - "storage_template_migration_description": "Aplicar o {template} atual para arquivos previamente carregados", - "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos arquivos. Para aplicar o modelo retroativamente para os arquivos carregados anteriormente, execute o {job}.", - "storage_template_migration_job": "Trabalho de migração do modelo de armazenamento", - "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e as suas implicações", - "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por defeito. Para mais informações, por favor leia a documentação.", + "storage_template_migration_description": "Aplica o {template} atual para ficheiros previamente carregados", + "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos ficheiros. Para aplicar o modelo retroativamente para os ficheiros carregados anteriormente, execute o {job}.", + "storage_template_migration_job": "Tarefa de Migração do Modelo de Armazenamento", + "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e às suas implicações", + "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por padrão. Para mais informações, por favor leia a documentação.", "storage_template_path_length": "Limite aproximado do tamanho do caminho: {length, number}{limit, number}", - "storage_template_settings": "Modelo de armazenamento", - "storage_template_settings_description": "Gerenciar a estrutura de pastas e o nome do arquivo dos ativos carregados", + "storage_template_settings": "Modelo de Armazenamento", + "storage_template_settings_description": "Gerir a estrutura de pastas e o nome do ficheiro carregado", "storage_template_user_label": "{label} é o Rótulo do Armazenamento do utilizador", - "system_settings": "Configurações de Sistema", - "theme_custom_css_settings": "CSS customizado", - "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", - "theme_settings": "Configurações de tema", - "theme_settings_description": "Gerencie a personalização da interface web do Immich", - "these_files_matched_by_checksum": "Esses arquivos são correspondidos por seus checksum", + "system_settings": "Definições de Sistema", + "tag_cleanup_job": "Limpeza de etiquetas", + "theme_custom_css_settings": "CSS Personalizado", + "theme_custom_css_settings_description": "Folhas de estilo em cascata (CSS) permitem que o design do Immich seja personalizado.", + "theme_settings": "Definições de Tema", + "theme_settings_description": "Gerir a personalização da interface web do Immich", + "these_files_matched_by_checksum": "Estes ficheiros são correspondidos pelas suas somas de verificação", "thumbnail_generation_job": "Gerar miniaturas", - "thumbnail_generation_job_description": "Gere miniaturas grandes, pequenas e desfocadas para cada ativo, bem como miniaturas para cada pessoa", + "thumbnail_generation_job_description": "Gera miniaturas grandes, pequenas e desfocadas para cada ficheiro, bem como miniaturas para cada pessoa", "transcode_policy_description": "", "transcoding_acceleration_api": "API de aceleração", - "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta configuração é a 'melhor opção': ela retornará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", + "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta definição é a 'melhor opção': ela voltará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", "transcoding_acceleration_nvenc": "NVENC (requer GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (requer CPU Intel de 7ª geração ou posterior)", "transcoding_acceleration_rkmpp": "RKMPP (apenas em SOCs Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", - "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_accepted_audio_codecs": "Codecs de áudio aceites", + "transcoding_accepted_audio_codecs_description": "Selecione os codecs de áudio que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_containers": "Contentores aceites", - "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remuxed para MP4. Apenas usados para algumas políticas de transcodificação.", + "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remisturados para MP4. Usado apenas para algumas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", - "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deveria precisar alterar", + "transcoding_accepted_video_codecs_description": "Selecione quais os codecs de vídeo que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deverá precisar de alterar", "transcoding_audio_codec": "Codec de áudio", - "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou softwares antigos.", - "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão em um formato aceito", - "transcoding_codecs_learn_more": "Para aprender mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", + "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou software antigos.", + "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão num formato aceite", + "transcoding_codecs_learn_more": "Para saber mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modo de qualidade constante", "transcoding_constant_quality_mode_description": "ICQ é melhor que CQP, mas alguns dispositivos de aceleração de hardware não suportam este modo. Definir esta opção dará preferência ao modo especificado ao usar codificação baseada em qualidade. Ignorado pelo NVENC porque não suporta ICQ.", "transcoding_constant_rate_factor": "Fator de taxa constante (-crf)", - "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz arquivos maiores.", - "transcoding_disabled_description": "Não transcodifique nenhum vídeo, pois pode interromper a reprodução em alguns clientes", + "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz ficheiros maiores.", + "transcoding_disabled_description": "Não transcodificar nenhum vídeo, no entanto pode causar erros de reprodução em alguns clientes", "transcoding_hardware_acceleration": "Aceleração de hardware", "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", "transcoding_hardware_decoding": "Decodificação de hardware", "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC, QSV e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", "transcoding_hevc_codec": "Codec HEVC", "transcoding_max_b_frames": "Máximo de quadros B", - "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", + "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", "transcoding_max_bitrate": "Taxa de bits máxima", - "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos arquivos mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", + "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos ficheiros mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", "transcoding_max_keyframe_interval": "Intervalo máximo de quadro-chave", - "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de busca e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", - "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou em formato não aceito", + "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de procura e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", + "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou num formato não aceite", "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", - "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápidas\".", + "transcoding_preset_preset": "Predefinição (-preset)", + "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem ficheiros menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápido\".", "transcoding_reference_frames": "Quadros de referência", - "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", - "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", - "transcoding_settings": "Configurações de transcodificação de vídeo", - "transcoding_settings_description": "Gerencie as informações de resolução e codificação dos arquivos de vídeo", + "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao comprimir um determinado quadro. Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. 0 define esse valor automaticamente.", + "transcoding_required_description": "Apenas vídeos que não estejam num formato aceite", + "transcoding_settings": "Definições de transcodificação de vídeo", + "transcoding_settings_description": "Gerir as informações de resolução e codificação dos ficheiros de vídeo", "transcoding_target_resolution": "Resolução desejada", - "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "transcoding_temporal_aq": "QA temporal", "transcoding_temporal_aq_description": "Aplica-se apenas ao NVENC. Aumenta a qualidade de cenas com alto detalhe e pouco movimento. Pode não ser compatível com dispositivos mais antigos.", "transcoding_threads": "Threads", - "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos da CPU. Maximiza a utilização se definido como 0.", + "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos do CPU. Maximiza a utilização se definido como 0.", "transcoding_tone_mapping": "Mapeamento de tons", "transcoding_tone_mapping_description": "Tenta preservar a aparência dos vídeos HDR quando convertidos para SDR. Cada algoritmo faz compensações diferentes em termos de cor, detalhes e brilho. Hable preserva os detalhes, Mobius preserva as cores e Reinhard preserva o brilho.", "transcoding_tone_mapping_npl": "NPL de mapeamento de tons", - "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho da tela. 0 define esse valor automaticamente.", + "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho do ecrã. 0 define esse valor automaticamente.", "transcoding_transcode_policy": "Política de transcodificação", - "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR sempre serão transcodificados (exceto se a transcodificação estiver desativada).", - "transcoding_two_pass_encoding": "Codificação de duas passagens", - "transcoding_two_pass_encoding_setting_description": "Transcodifique em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está habilitada (necessária para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desabilitada.", + "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR serão sempre transcodificados (exceto se a transcodificação estiver desativada).", + "transcoding_two_pass_encoding": "Codificação em duas passagens", + "transcoding_two_pass_encoding_setting_description": "Transcodificar em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está ativada (necessário para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desativada.", "transcoding_video_codec": "Codec de vídeo", - "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz arquivos muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", - "trash_enabled_description": "Ativar recursos da Lixeira", + "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz ficheiros muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", + "trash_enabled_description": "Ativar funcionalidade da Reciclagem", "trash_number_of_days": "Número de dias", - "trash_number_of_days_description": "Número de dias para manter os arquivos na lixeira antes de eliminar permanentemente", - "trash_settings": "Configurações da Lixeira", - "trash_settings_description": "Gerenciar configurações da lixeira", - "untracked_files": "Arquivos não rastreados", - "untracked_files_description": "Esses arquivos não são rastreados pelo aplicativo. Eles podem ser o resultado de movimentos malsucedidos, carregamentos interrompidos ou deixados para trás devido a um bug", - "user_delete_delay": "A conta e os arquivos de {user} serão agendados para eliminação permanente em {delay, plural, one {# dia} other {# dias}}.", - "user_delete_delay_settings": "Excluir atraso", - "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um utilizador. O trabalho de exclusão de utilizadores é executado à meia-noite para verificar utilizadores que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão enfileirados para exclusão permanente imediatamente.", - "user_delete_immediately_checkbox": "Adicionar utilizador e arquivos à fila para eliminação imediata", - "user_management": "Gerenciamento de utilizadores", - "user_password_has_been_reset": "A senha do utilizador foi redefinida:", - "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", + "trash_number_of_days_description": "Número de dias para manter os ficheiros na reciclagem antes de os eliminar permanentemente", + "trash_settings": "Definições da Reciclagem", + "trash_settings_description": "Gerir definições da reciclagem", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_description": "Estes ficheiros não são monitorizados pela aplicação. Eles podem ser o resultado de transferências mal-sucedidas, carregamentos interrompidos ou deixados para trás devido a um problema", + "user_cleanup_job": "Limpeza de utilizadores", + "user_delete_delay": "A conta e os ficheiros de {user} serão agendados para eliminação permanente dentro de {delay, plural, one {# dia} other {# dias}}.", + "user_delete_delay_settings": "Atraso de eliminação", + "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os ficheiros de um utilizador. A tarefa de eliminação de utilizadores é executada à meia-noite para verificar utilizadores que estão prontos para eliminação. As alterações a esta definição serão avaliadas na próxima execução.", + "user_delete_immediately": "A conta e os ficheiros de {user} serão colocados em fila para eliminação permanente de imediato.", + "user_delete_immediately_checkbox": "Adicionar utilizador e ficheiros à fila para eliminação imediata", + "user_management": "Gestão de utilizadores", + "user_password_has_been_reset": "A palavra-passe do utilizador foi redefinida:", + "user_password_reset_description": "Por favor forneça a palavra-passe temporária ao utilizador e informe-o(a) de que será necessário alterá-la próximo início de sessão.", "user_restore_description": "A conta de {user} será restaurada.", - "user_restore_scheduled_removal": "Restaurar usuário - planejar remoção em {date, date, long}", - "user_settings": "Configurações do Utilizador", - "user_settings_description": "Gerenciar configurações do utilizador", + "user_restore_scheduled_removal": "Restaurar utilizador - remoção agendada em {date, date, long}", + "user_settings": "Definições do Utilizador", + "user_settings_description": "Gerir definições do utilizador", "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", "version_check_enabled_description": "Ativa verificação de novas versões", - "version_check_implications": "A funcionalidade de verificação da versão necessita comunicação periodica com github.com", + "version_check_implications": "A funcionalidade de verificação da versão necessita de comunicação periódica com o github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", - "video_conversion_job_description": "Transcodifique vídeos para maior compatibilidade com navegadores e dispositivos" + "video_conversion_job_description": "Transcodificar vídeos para maior compatibilidade com navegadores e dispositivos" }, "admin_email": "E-mail do administrador", - "admin_password": "Senha do administrador", + "admin_password": "Palavra-passe do administrador", "administration": "Administração", "advanced": "Avançado", "age_months": "Idade {months, plural, one {# mês} other {# meses}}", "age_year_months": "Idade 1 ano, {months, plural, one {# mês} other {# meses}}", - "age_years": "Idade {years, plural, one{# ano} other {# anos}}", + "age_years": "{years, plural, one{# ano} other {# anos}}", "album_added": "Álbum adicionado", - "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", + "album_added_notification_setting_description": "Receber uma notificação por e-mail quando for adicionado a um álbum partilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "Tem a certeza que quer apagar o álbum {album}? Se o álbum for partilhado, os outros utilizadores não poderão aceder-lhe novamente.", - "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de poder aceder.", + "album_delete_confirmation": "Tem a certeza de que quer eliminar o álbum {album}?", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de o poder aceder.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", - "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", + "album_leave_confirmation": "Tem a certeza de que quer sair de {album}?", "album_name": "Nome do álbum", "album_options": "Opções de álbum", "album_remove_user": "Remover utilizador?", - "album_remove_user_confirmation": "Tem a certeza que quer remover {user}?", - "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores para o partilhar.", + "album_remove_user_confirmation": "Tem a certeza de que quer remover {user}?", + "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores com quem o partilhar.", "album_updated": "Álbum atualizado", - "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", - "album_user_left": "Saída {album}", + "album_updated_setting_description": "Receber uma notificação por e-mail quando um álbum partilhado tiver novos ficheiros", + "album_user_left": "Saíu do {album}", "album_user_removed": "Utilizador {user} removido", - "album_with_link_access": "Permite acesso a fotos e pessoas deste album por qualquer pessoa com o link.", + "album_with_link_access": "Permite o acesso a fotos e pessoas deste álbum por qualquer pessoa com o link.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", @@ -362,68 +371,69 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permit acesso de download ao user publico", - "allow_public_user_to_upload": "Permite acesso de upload ao user publico", + "allow_public_user_to_download": "Permitir que utilizadores públicos façam transferências", + "allow_public_user_to_upload": "Permitir que utilizadores públicos façam carregamentos", "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", - "api_key_empty": "O nome da API Key não pode ser vazio", + "api_key_empty": "O nome da chave a API não pode estar vazio", "api_keys": "Chaves de API", - "app_settings": "Configurações do Aplicativo", + "app_settings": "Definições da Aplicação", "appears_in": "Aparece em", "archive": "Arquivo", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", "archive_size": "Tamanho do arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size_description": "Configure o tamanho do arquivo para transferências (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, other {Arquivado #}}", - "are_these_the_same_person": "São a mesma pessoa?", - "are_you_sure_to_do_this": "Tem a certeza que quer fazer isto?", + "are_these_the_same_person": "Estas pessoas são a mesma pessoa?", + "are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "A adicionar ao álbum...", - "asset_description_updated": "A descrição do arquivo foi atualizada", - "asset_filename_is_offline": "O arquivo {filename} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", - "asset_hashing": "Hashing...", - "asset_offline": "Ativo off-line", - "asset_offline_description": "Este arquivo está offline. Immich não consegue acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e, em seguida, escaneie a biblioteca novamente.", + "asset_description_updated": "A descrição do ficheiro foi atualizada", + "asset_filename_is_offline": "O ficheiro {filename} não está disponível", + "asset_has_unassigned_faces": "O ficheiro tem rostos não atribuídas", + "asset_hashing": "A criar hash...", + "asset_offline": "Ficheiro indisponível", + "asset_offline_description": "Este ficheiro está indisponível. O Immich não consegue aceder ao local do local. Certifique-se de que o ficheiro está disponível e, em seguida, analise a biblioteca novamente.", "asset_skipped": "Ignorado", + "asset_skipped_in_trash": "Na reciclagem", "asset_uploaded": "Enviado", - "asset_uploading": "Em upload...", - "assets": "Arquivos", - "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", - "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", - "assets_added_to_name_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", - "assets_count": "{count, plural, one {# arquivo} other {# arquivos}}", + "asset_uploading": "A enviar...", + "assets": "Ficheiros", + "assets_added_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}", + "assets_added_to_album_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} ao álbum", + "assets_added_to_name_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", + "assets_count": "{count, plural, one {# ficheiro} other {# ficheiros}}", "assets_moved_to_trash": "{count, plural, one {# ativo enviado} other {# ativos enviados}} para a lixeira", - "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", - "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", - "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", - "assets_restore_confirmation": "Tem a certeza que quer recuperar todos os artigos apagados? Não é possivel voltar atrás nesta acção!", - "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", - "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", - "assets_were_part_of_album_count": "{count, plural, one {Arquivo já era} other {Os arquivos já eram}} parte do álbum", + "assets_moved_to_trash_count": "{count, plural, one {# ficheiro movido} other {# aficheiros movidos}} para a reciclagem", + "assets_permanently_deleted_count": "{count, plural, one {# ficheiro} other {# ficheiros}} eliminados permanentemente", + "assets_removed_count": "{count, plural, one {# ficheiro eliminado} other {# ficheiros eliminados}}", + "assets_restore_confirmation": "Tem a certeza de que quer recuperar todos os ficheiros apagados? Não é possível anular esta ação!", + "assets_restored_count": "{count, plural, one {# ficheiro restaurado} other {# ficheiros restaurados}}", + "assets_trashed_count": "{count, plural, one {# ficheiro enviado} other {# ficheiros enviados}} para a reciclagem", + "assets_were_part_of_album_count": "{count, plural, one {O ficheiro já fazia} other {Os ficheiros já faziam}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento guardada com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", + "birthdate_set_description": "A data de nascimento é utilizada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", - "build": "Construir", - "build_image": "Construir Imagem", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", - "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", - "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", + "build": "Versão de compilação", + "build_image": "Imagem de compilação", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Esta ação mantém o maior ficheiro de cada grupo e elimina permanentemente todos os outros duplicados. Não é possível anular esta ação!", + "bulk_keep_duplicates_confirmation": "Tem a certeza de que deseja manter {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto resolverá todos os grupos duplicados sem eliminar nada.", + "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a recicalgem {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto manterá o maior ficheiro de cada grupo e irá mover para a reciclagem todos os outros duplicados.", "buy": "Comprar Immich", - "camera": "Câmera", - "camera_brand": "Marca da câmera", - "camera_model": "Modelo da câmera", + "camera": "Câmara", + "camera_brand": "Marca da câmara", + "camera_model": "Modelo da câmara", "cancel": "Cancelar", "cancel_search": "Cancelar pesquisa", - "cannot_merge_people": "Não é possível mesclar pessoas", - "cannot_undo_this_action": "Não pode voltar atrás nesta ação!", - "cannot_update_the_description": "Não é possível atualizar a descrição", + "cannot_merge_people": "Não foi possível unir pessoas", + "cannot_undo_this_action": "Não é possível anular esta ação!", + "cannot_update_the_description": "Não foi possível atualizar a descrição", "cant_apply_changes": "Não é possível aplicar alterações", "cant_get_faces": "Não foi possível obter faces", "cant_search_people": "Não foi possível pesquisar pessoas", @@ -433,13 +443,13 @@ "change_location": "Alterar localização", "change_name": "Alterar nome", "change_name_successfully": "Nome alterado com sucesso", - "change_password": "Mudar a senha", - "change_password_description": "Esta é a primeira vez que você está entrando no sistema ou uma solicitação foi feita para alterar sua senha. Insira a nova senha abaixo.", - "change_your_password": "Alterar sua senha", + "change_password": "Alterar a palavra-passe", + "change_password_description": "Esta é a primeira vez que está a entrar no sistema ou um pedido foi feito para alterar a sua palavra-passe. Insira a nova palavra-passe abaixo.", + "change_your_password": "Alterar a sua palavra-passe", "changed_visibility_successfully": "Visibilidade alterada com sucesso", "check_all": "Verificar tudo", - "check_logs": "Verificar registros", - "choose_matching_people_to_merge": "Escolha pessoas correspondentes para mesclar", + "check_logs": "Verificar registos", + "choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir", "city": "Cidade", "clear": "Limpar", "clear_all": "Limpar tudo", @@ -450,26 +460,27 @@ "close": "Fechar", "collapse": "Colapsar", "collapse_all": "Colapsar tudo", - "color_theme": "Tema de cores", + "color": "Cor", + "color_theme": "Esquema de cores", "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", "comments_and_likes": "Comentários e gostos", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", - "confirm_admin_password": "Confirmar senha de administrador", - "confirm_delete_shared_link": "Tem certeza de que deseja excluir este link compartilhado?", - "confirm_password": "Confirme a senha", - "contain": "Caber", + "confirm_admin_password": "Confirmar palavra-passe de administrador", + "confirm_delete_shared_link": "Tem certeza de que deseja eliminar este link partilhado?", + "confirm_password": "Confirmar a palavra-passe", + "contain": "Ajustar", "context": "Contexto", "continue": "Continuar", "copied_image_to_clipboard": "Imagem copiada para a área de transferência.", "copied_to_clipboard": "Copiado para a área de transferência!", "copy_error": "Copiar erro", - "copy_file_path": "Copiar caminho do arquivo", + "copy_file_path": "Copiar caminho do ficheiro", "copy_image": "Copiar Imagem", "copy_link": "Copiar link", "copy_link_to_clipboard": "Copiar link para a área de transferência", - "copy_password": "Copiar senha", + "copy_password": "Copiar palavra-passe", "copy_to_clipboard": "Copiar para a área de transferência", "country": "País", "cover": "Preencher", @@ -479,15 +490,17 @@ "create_library": "Criar biblioteca", "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", - "create_link_to_share_description": "Permiter a visualização desta imagem(s) a qualquer pessoa com este link", + "create_link_to_share_description": "Permitir a visualização desta(s) imagem(s) a qualquer pessoa com o link", "create_new_person": "Criar nova pessoa", - "create_new_person_hint": "Associe os arquivos para uma nova pessoa", + "create_new_person_hint": "Associe os ficheiros a uma nova pessoa", "create_new_user": "Criar novo utilizador", + "create_tag": "Criar etiqueta", + "create_tag_description": "Criar uma nova etiqueta. Para etiquetas compostas, introduza o caminho completo, incluindo as barras.", "create_user": "Criar utilizador", "created": "Criado", "current_device": "Dispositivo atual", - "custom_locale": "Localização Customizada", - "custom_locale_description": "Formatar datas e números baseados na linguagem e região", + "custom_locale": "Localização Personalizada", + "custom_locale_description": "Formatar datas e números baseados na língua e na região", "dark": "Escuro", "date_after": "Data após", "date_and_time": "Data e Hora", @@ -495,19 +508,21 @@ "date_of_birth_saved": "Data de nascimento guardada com sucesso", "date_range": "Intervalo de datas", "day": "Dia", - "deduplicate_all": "Limpar todas Duplicidades", + "deduplicate_all": "Limpar todos os itens duplicados", "default_locale": "Localização Padrão", "default_locale_description": "Formatar datas e números baseados na linguagem do seu navegador", - "delete": "Excluir", - "delete_album": "Excluir álbum", - "delete_api_key_prompt": "Tem certeza de que deseja excluir esta chave de API?", - "delete_duplicates_confirmation": "Tem certeza de que deseja excluir permanentemente estas duplicidades?", - "delete_key": "Excluir chave", - "delete_library": "Excluir biblioteca", - "delete_link": "Excluir link", - "delete_shared_link": "Excluir link de compartilhamento", - "delete_user": "Excluir utilizador", - "deleted_shared_link": "Link de compartilhamento excluído", + "delete": "Eliminar", + "delete_album": "Eliminar álbum", + "delete_api_key_prompt": "Tem certeza de que deseja eliminar esta chave de API?", + "delete_duplicates_confirmation": "Tem certeza de que deseja eliminar permanentemente estes itens duplicados?", + "delete_key": "Eliminar chave", + "delete_library": "Eliminar biblioteca", + "delete_link": "Eliminar link", + "delete_shared_link": "Eliminar link de partilha", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Tem a certeza de que pretende eliminar a etiqueta {tagName} ?", + "delete_user": "Eliminar utilizador", + "deleted_shared_link": "Link de partilha eliminado", "description": "Descrição", "details": "Detalhes", "direction": "Direção", @@ -519,19 +534,19 @@ "display_options": "Opções de exibição", "display_order": "Ordem de exibição", "display_original_photos": "Exibir fotos originais", - "display_original_photos_setting_description": "Prefira exibir a foto original ao visualizar um ativo em vez de miniaturas quando o ativo original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", + "display_original_photos_setting_description": "Preferir a exibição da foto original ao visualizar um ficheiro em vez de miniaturas quando o ficheiro original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Transferir", "download_include_embedded_motion_videos": "Vídeos incorporados", - "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um arquivo separado", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um ficheiro separado", "download_settings": "Transferir", - "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", - "downloading": "Baixando", - "downloading_asset_filename": "A transferir o arquivo {filename}", - "drop_files_to_upload": "Coloque os ficheiros em qualquer lugar para fazer o upload", - "duplicates": "Duplicados", - "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", + "download_settings_description": "Gerir definições relacionadas com a transferência de ficheiros", + "downloading": "A transferir", + "downloading_asset_filename": "A transferir o ficheiro {filename}", + "drop_files_to_upload": "Solte os ficheiros em qualquer lugar para os enviar", + "duplicates": "Itens duplicados", + "duplicates_description": "Marque cada grupo indicando quais ficheiros, se algum, são duplicados", "duration": "Duração", "durations": { "days": "", @@ -542,11 +557,11 @@ }, "edit": "Editar", "edit_album": "Editar álbum", - "edit_avatar": "Editar foto de perfil", + "edit_avatar": "Editar imagem de perfil", "edit_date": "Editar data", "edit_date_and_time": "Editar data e hora", "edit_exclusion_pattern": "Editar o padrão de exclusão", - "edit_faces": "Editar faces", + "edit_faces": "Editar rostos", "edit_import_path": "Editar caminho de importação", "edit_import_paths": "Editar caminhos de importação", "edit_key": "Editar chave", @@ -554,150 +569,153 @@ "edit_location": "Editar Localização", "edit_name": "Editar nome", "edit_people": "Editar pessoas", + "edit_tag": "Editar etiqueta", "edit_title": "Editar Título", "edit_user": "Editar utilizador", "edited": "Editado", - "editor": "Editar", - "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor": "Editor", + "editor_close_without_save_prompt": "As alterações não serão guardadas", "editor_close_without_save_title": "Fechar editor?", - "editor_crop_tool_h2_aspect_ratios": "Proporções de aspecto", + "editor_crop_tool_h2_aspect_ratios": "Relação de aspeto", "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", - "empty_trash": "Esvaziar lixo", - "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a lixeira? Isso removerá todos os arquivos da lixeira do Immich permanentemente.\nVocê não pode desfazer esta ação!", + "empty_trash": "Esvaziar reciclagem", + "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a reciclagem? Isto removerá todos os ficheiros da reciclagem do Immich permanentemente.\nNão é possível anular esta ação!", "enable": "Ativar", "enabled": "Ativado", "end_date": "Data final", "error": "Erro", - "error_loading_image": "Erro ao carregar a página", + "error_loading_image": "Erro ao carregar a imagem", "error_title": "Erro - Algo correu mal", "errors": { - "cannot_navigate_next_asset": "Não pode navegar para o proximo artigo", - "cannot_navigate_previous_asset": "Não pode navegar para o artigo anterior", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo ficheiro", + "cannot_navigate_previous_asset": "Não foi possível navegar para o ficheiro anterior", "cant_apply_changes": "Não foi possível aplicar as alterações", - "cant_change_activity": "Não é possível {enabled, select, true {desativar} other {ativar}} atividade", - "cant_change_asset_favorite": "Não pode alterar o favorito deste artigo", - "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não foi possível alterar o favorito deste ficheiro", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# ficheiro} other {# ficheiros}}", "cant_get_faces": "Não foi possível obter os rostos", "cant_get_number_of_comments": "Não foi possível obter o número de comentários", "cant_search_people": "Não foi possível pesquisar pessoas", "cant_search_places": "Não foi possível pesquisar locais", - "cleared_jobs": "Trabalhos eliminados para: {job}", - "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", - "error_adding_users_to_album": "Erro a adicionar utilizador ao album", - "error_deleting_shared_user": "Error a apagar o utilizador partilhado", - "error_downloading": "Erro a transferir {filename}", + "cleared_jobs": "Tarefas eliminadas para: {job}", + "error_adding_assets_to_album": "Erro ao adicionar ficheiros ao álbum", + "error_adding_users_to_album": "Erro ao adicionar utilizador ao álbum", + "error_deleting_shared_user": "Erro ao apagar o utilizador partilhado", + "error_downloading": "Erro ao transferir {filename}", "error_hiding_buy_button": "Erro ao esconder botão de compra", - "error_removing_assets_from_album": "Erro a eliminar artigos do album, verifique a consola para mais detalhes", - "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", + "error_removing_assets_from_album": "Erro ao eliminar ficheiros do álbum, verifique a consola para mais detalhes", + "error_selecting_all_assets": "Erro ao selecionar todos os ficheiros", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", - "failed_job_command": "Comando {command} falhou para o trabalho: {job}", - "failed_to_create_album": "Falha ao criar álbum", - "failed_to_create_shared_link": "Falhou a criar um link partilhado", - "failed_to_edit_shared_link": "Falhou a editar o link partilhado", - "failed_to_get_people": "Falha na obtenção de pessoas", - "failed_to_load_asset": "Falha ao carregar arquivo", - "failed_to_load_assets": "Falha ao carregar arquivos", - "failed_to_load_people": "Falha ao carregar pessoas", - "failed_to_remove_product_key": "Falha ao remover chave de produto", - "failed_to_stack_assets": "Falha ao empilhar os arquivos", - "failed_to_unstack_assets": "Falha ao desempilhar arquivos", + "failed_job_command": "Comando {command} falhou para a tarefa: {job}", + "failed_to_create_album": "Não foi possível criar álbum", + "failed_to_create_shared_link": "Não foi possível criar o link partilhado", + "failed_to_edit_shared_link": "Não foi possível editar o link partilhado", + "failed_to_get_people": "Não foi possível obter pessoas", + "failed_to_load_asset": "Não foi possível ler o ficheiro", + "failed_to_load_assets": "Não foi possível ler ficheiros", + "failed_to_load_people": "Não foi possível carregar pessoas", + "failed_to_remove_product_key": "Não foi possível remover chave de produto", + "failed_to_stack_assets": "Não foi possível empilhar os ficheiros", + "failed_to_unstack_assets": "Não foi possível desempilhar ficheiros", "import_path_already_exists": "Este caminho de importação já existe.", - "incorrect_email_or_password": "Email ou password incorretos", - "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", - "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixels transparentes. Por favor faça zoom in e/ou mova a imagem.", - "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", + "incorrect_email_or_password": "Email ou palavra-passe incorretos", + "paths_validation_failed": "A validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixeis transparentes. Por favor amplie e/ou mova a imagem.", + "quota_higher_than_disk_size": "Definiu uma quota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", - "unable_to_add_assets_to_shared_link": "Não foi possivel adicionar os artigos ao link partilhado", + "unable_to_add_assets_to_shared_link": "Não foi possível adicionar os ficheiros ao link partilhado", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", - "unable_to_add_remove_archive": "Não é possível {archived, select, true {remover o arquivo de} other {adicionar o arquivo}}", - "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {arquivar} other {desarquivar}}", + "unable_to_add_remove_archive": "Não foi possível {archived, select, true {remover o ficheiro de} other {adicionar o ficheiro}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar ficheiro aos} other {remover ficheiro dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não foi possivel mudar o favorito do artigo", + "unable_to_change_favorite": "Não foi possível mudar o favorito do ficheiro", "unable_to_change_location": "Não foi possível alterar a localização", - "unable_to_change_password": "Não foi possível alterar a senha", + "unable_to_change_password": "Não foi possível alterar a palavra-passe", "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", "unable_to_check_item": "", "unable_to_check_items": "", - "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", - "unable_to_connect": "Não é possível conectar", + "unable_to_complete_oauth_login": "Não foi possível completar o início de sessão com OAuth", + "unable_to_connect": "Não é possível ligar", "unable_to_connect_to_server": "Não foi possível ligar ao servidor", - "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", + "unable_to_copy_to_clipboard": "Não foi possível copiar para a área de transferência, certifique-se de que está a aceder à pagina através de https", "unable_to_create_admin_account": "Não foi possível criar conta de administrador", "unable_to_create_api_key": "Não foi possível criar uma nova Chave de API", "unable_to_create_library": "Não foi possível criar a biblioteca", "unable_to_create_user": "Não foi possível criar o utilizador", - "unable_to_delete_album": "Não foi possível deletar o álbum", - "unable_to_delete_asset": "Não foi possível deletar o ativo", - "unable_to_delete_assets": "Erro ao eliminar arquivos", - "unable_to_delete_exclusion_pattern": "Não foi possível deletar o padrão de exclusão", - "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", - "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", - "unable_to_delete_user": "Não foi possível deletar o utilizador", + "unable_to_delete_album": "Não foi possível eliminar o álbum", + "unable_to_delete_asset": "Não foi possível eliminar o ficheiro", + "unable_to_delete_assets": "Erro ao eliminar ficheiros", + "unable_to_delete_exclusion_pattern": "Não foi possível eliminar o padrão de exclusão", + "unable_to_delete_import_path": "Não foi possível eliminar o caminho de importação", + "unable_to_delete_shared_link": "Não foi possível eliminar o link compartilhado", + "unable_to_delete_user": "Não foi possível eliminar o utilizador", "unable_to_download_files": "Não foi possível transferir ficheiros", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", - "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", - "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", - "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", + "unable_to_empty_trash": "Não foi possível esvaziar a reciclagem", + "unable_to_enter_fullscreen": "Não foi possível entrar em modo de ecrã inteiro", + "unable_to_exit_fullscreen": "Não foi possível sair do modo de ecrã inteiro", "unable_to_get_comments_number": "Não foi possível obter número de comentários", - "unable_to_get_shared_link": "Falha ao obter link compartilhado", + "unable_to_get_shared_link": "Não foi possível obter link partilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", + "unable_to_link_motion_video": "Não foi possível relacionar o video animado", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", - "unable_to_load_asset_activity": "Não foi possível carregar as atividades do ativo", - "unable_to_load_items": "Não foi possível carregar os items", - "unable_to_load_liked_status": "Não foi possível carregar os status de gostei", + "unable_to_load_asset_activity": "Não foi possível carregar a atividade do ficheiro", + "unable_to_load_items": "Não foi possível carregar os itens", + "unable_to_load_liked_status": "Não foi possível carregar o estado de gostos", "unable_to_log_out_all_devices": "Não foi possível terminar a sessão em todos os dispositivos", "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não é possível reatribuir arquivos para {name, select, null {uma pessoa existente} other {{name}}}", - "unable_to_reassign_assets_new_person": "Não é possível reatribuir os arquivos a uma nova pessoa", - "unable_to_refresh_user": "Não foi possível atualizar o utilizador", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir ficheiros para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não foi possível reatribuir os ficheiros a uma nova pessoa", + "unable_to_refresh_user": "Não foi possível recarregar o utilizador", "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", - "unable_to_remove_api_key": "Não foi possível a Chave de API", - "unable_to_remove_assets_from_shared_link": "Não é possível remover os arquivos do link compartilhado", + "unable_to_remove_api_key": "Não foi possível remover a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não foi possível remover os ficheiros do link partilhado", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", - "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", + "unable_to_remove_offline_files": "Não foi possível remover ficheiros indisponíveis", "unable_to_remove_partner": "Não foi possível remover parceiro", "unable_to_remove_reaction": "Não foi possível remover a reação", "unable_to_remove_user": "", "unable_to_repair_items": "Não foi possível reparar os itens", - "unable_to_reset_password": "Não foi possível resetar a senha", - "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar arquivos", - "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", + "unable_to_reset_password": "Não foi possível redefinir a palavra-passe", + "unable_to_resolve_duplicate": "Não foi possível resolver as duplicidades", + "unable_to_restore_assets": "Não foi possível restaurar ficheiros", + "unable_to_restore_trash": "Não foi possível restaurar itens da reciclagem", "unable_to_restore_user": "Não foi possível restaurar utilizador", - "unable_to_save_album": "Não foi possível salvar o álbum", - "unable_to_save_api_key": "Não foi possível salvar a Chave de API", + "unable_to_save_album": "Não foi possível guardar o álbum", + "unable_to_save_api_key": "Não foi possível guardar a Chave de API", "unable_to_save_date_of_birth": "Não foi possível guardar a data de nascimento", - "unable_to_save_name": "Não foi possível salvar o nome", - "unable_to_save_profile": "Não foi possível salvar o perfil", - "unable_to_save_settings": "Não foi possível salvar as configurações", - "unable_to_scan_libraries": "Não foi possível escanear as bibliotecas", - "unable_to_scan_library": "Não foi possível escanear a biblioteca", - "unable_to_set_feature_photo": "Não é possível definir a foto do recurso", + "unable_to_save_name": "Não foi possível guardar o nome", + "unable_to_save_profile": "Não foi possível guardar o perfil", + "unable_to_save_settings": "Não foi possível guardar as definições", + "unable_to_scan_libraries": "Não foi possível analisar as bibliotecas", + "unable_to_scan_library": "Não foi possível analisar a biblioteca", + "unable_to_set_feature_photo": "Não foi possível definir a foto de destaque", "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", - "unable_to_submit_job": "Não foi possível enviar o trabalho", - "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", + "unable_to_submit_job": "Não foi possível enviar a tarefa", + "unable_to_trash_asset": "Não foi possível enviar o ficheiro para a reciclagem", "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_unlink_motion_video": "Não foi possível remover a relação com o video animado", "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", "unable_to_update_album_info": "Não foi possível atualizar informações do álbum", "unable_to_update_library": "Não foi possível atualizar a biblioteca", "unable_to_update_location": "Não foi possível atualizar a localização", - "unable_to_update_settings": "Não foi possível atualizar as configurações", + "unable_to_update_settings": "Não foi possível atualizar as definições", "unable_to_update_timeline_display_status": "Não foi possível atualizar o modo de visualização da linha do tempo", - "unable_to_update_user": "Não foi possível atualizar o usuário", + "unable_to_update_user": "Não foi possível atualizar o utilizador", "unable_to_upload_file": "Não foi possível carregar o ficheiro" }, "every_day_at_onepm": "", @@ -707,7 +725,7 @@ "exif": "Exif", "exit_slideshow": "Sair da apresentação", "expand_all": "Expandir tudo", - "expire_after": "Expira depois", + "expire_after": "Expira depois de", "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", @@ -720,38 +738,41 @@ "face_unassigned": "Sem atribuição", "failed_to_get_people": "Falha ao carregar as pessoas", "favorite": "Favorito", - "favorite_or_unfavorite_photo": "Marque ou desmarque a foto como favorita", + "favorite_or_unfavorite_photo": "Marcar ou desmarcar a foto como favorita", "favorites": "Favoritos", "feature": "", "feature_photo_updated": "Foto principal atualizada", "featurecollection": "", - "file_name": "Nome do arquivo", - "file_name_or_extension": "Nome do arquivo ou extensão", - "filename": "Nome do arquivo", + "features": "Funcionalidades", + "features_setting_description": "Configurar as funcionalidades da aplicação", + "file_name": "Nome do ficheiro", + "file_name_or_extension": "Nome do ficheiro ou extensão", + "filename": "Nome do ficheiro", "files": "", - "filetype": "Tipo de arquivo", + "filetype": "Tipo de ficheiro", "filter_people": "Filtrar pessoas", - "find_them_fast": "Encontre pelo nome em uma pesquisa", + "find_them_fast": "Encontre-as mais rapidamente pelo nome numa pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", "folders": "Pastas", - "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", - "forward": "Para frente", + "folders_feature_description": "A navegar na vista de pastas pelas fotos e vídeos no sistema de ficheiros", + "force_re-scan_library_files": "Forçar uma nova análise de todos os ficheiros da biblioteca", + "forward": "Para a frente", "general": "Geral", "get_help": "Obter Ajuda", - "getting_started": "Primeiros passos", - "go_back": "Voltar", + "getting_started": "Primeiros Passos", + "go_back": "Regressar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", "group_year": "Agrupar por ano", - "has_quota": "Há cota", + "has_quota": "Tem quota", "hi_user": "Olá {name} ({email})", "hide_all_people": "Ocultar todas as pessoas", "hide_gallery": "Ocultar galeria", "hide_named_person": "Ocultar pessoa {name}", - "hide_password": "Ocultar senha", + "hide_password": "Ocultar palavra-passe", "hide_person": "Ocultar pessoa", "hide_unnamed_people": "Ocultar pessoas sem nome", "host": "Host", @@ -768,33 +789,33 @@ "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", "img": "", - "immich_logo": "Logo do Immich", - "immich_web_interface": "Interface web do Immich", - "import_from_json": "Importar do JSON", + "immich_logo": "Logotipo do Immich", + "immich_web_interface": "Interface Web do Immich", + "import_from_json": "Importar a partir de JSON", "import_path": "Caminho de importação", "in_albums": "Em {count, plural, one {# álbum} other {# álbuns}}", "in_archive": "Arquivado", "include_archived": "Incluir arquivados", - "include_shared_albums": "Incluir álbuns compartilhados", - "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", - "individual_share": "Compartilhamento único", + "include_shared_albums": "Incluir álbuns partilhados", + "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", + "individual_share": "Partilha individual", "info": "Informações", "interval": { - "day_at_onepm": "Todo dia, 1pm", + "day_at_onepm": "Todos os dias, às 13:00", "hours": "A cada {hours, plural, one {hora} other {{hours, number} horas}}", - "night_at_midnight": "Toda noite, meia noite", - "night_at_twoam": "Toda noite, 2am" + "night_at_midnight": "Todas as noites, à meia noite", + "night_at_twoam": "Todas as noites, às 02:00" }, "invite_people": "Convidar Pessoas", "invite_to_album": "Convidar para o álbum", "items_count": "{count, plural, one {item #} other {itens #}}", "job_settings_description": "", - "jobs": "Trabalhos", + "jobs": "Tarefas", "keep": "Manter", "keep_all": "Manter Todos", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", - "language_setting_description": "Selecione seu Idioma preferido", + "language_setting_description": "Selecione o seu Idioma preferido", "last_seen": "Visto pela ultima vez", "latest_version": "Versão mais recente", "latitude": "Latitude", @@ -804,64 +825,65 @@ "library": "Biblioteca", "library_options": "Opções da biblioteca", "light": "Claro", - "like_deleted": "Curtida removida", + "like_deleted": "Gosto removido", + "link_motion_video": "Relacionar video animado", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", - "linked_oauth_account": "Conta OAuth Vinculada", + "linked_oauth_account": "Conta OAuth Associada", "list": "Lista", - "loading": "Carregando", - "loading_search_results_failed": "Falha ao carregar os resultados da pesquisa", + "loading": "A Carregar", + "loading_search_results_failed": "Não foi possível carregar os resultados da pesquisa", "log_out": "Sair", - "log_out_all_devices": "Sair de todos dispositivos", + "log_out_all_devices": "Terminar a sessão de todos os dispositivos", "logged_out_all_devices": "Sessão terminada em todos os dispositivos", "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", - "login_has_been_disabled": "Login foi desativado.", - "logout_all_device_confirmation": "Tem certeza de que deseja desconectar todos os dispositivos?", - "logout_this_device_confirmation": "Tem certeza de que deseja sair deste dispositivo?", + "login_has_been_disabled": "Início de sessão foi desativado.", + "logout_all_device_confirmation": "Tem certeza de que deseja terminar a sessão em todos os dispositivos?", + "logout_this_device_confirmation": "Tem certeza de que deseja terminar a sessão deste dispositivo?", "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", - "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", + "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", "make": "Marca", "manage_shared_links": "Gerir links partilhados", - "manage_sharing_with_partners": "Gerenciar compartilhamento com parceiros", - "manage_the_app_settings": "Gerenciar configurações do app", - "manage_your_account": "Gerenciar sua conta", - "manage_your_api_keys": "Gerenciar suas Chaves de API", - "manage_your_devices": "Gerenciar seus dispositivos logados", - "manage_your_oauth_connection": "Gerenciar sua conexão OAuth", + "manage_sharing_with_partners": "Gerir partilha com parceiros", + "manage_the_app_settings": "Gerir definições da aplicação", + "manage_your_account": "Gerir a sua conta", + "manage_your_api_keys": "Gerir as suas Chaves de API", + "manage_your_devices": "Gerir os seus dispositivos com sessão iniciada", + "manage_your_oauth_connection": "Gerir a sua ligação ao OAuth", "map": "Mapa", "map_marker_for_images": "Marcador no mapa para fotos tiradas em {city}, {country}", "map_marker_with_image": "Marcador de mapa com imagem", "map_settings": "Definições do mapa", "matches": "Correspondências", - "media_type": "Tipo de mídia", + "media_type": "Tipo de média", "memories": "Memórias", - "memories_setting_description": "Gerencie o que vê em suas memórias", + "memories_setting_description": "Gerir o que vê nas suas memórias", "memory": "Memória", "memory_lane_title": "Memórias {title}", "menu": "Menu", - "merge": "Mesclar", - "merge_people": "Mesclar pessoas", - "merge_people_limit": "Só é possível mesclar até 5 faces de uma só vez", - "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", - "merge_people_successfully": "Pessoas mescladas com sucesso", - "merged_people_count": "Mesclada {count, plural, one {1 pessoa} other {# pessoas}}", + "merge": "Unir", + "merge_people": "Unir pessoas", + "merge_people_limit": "Só é possível unir até 5 rostos de cada vez", + "merge_people_prompt": "Tem a certeza de que deseja unir estas pessoas? Esta ação é irreversível.", + "merge_people_successfully": "Pessoas unidas com sucesso", + "merged_people_count": "Unidas {count, plural, one {# pessoa} other {# pessoas}}", "minimize": "Minimizar", "minute": "Minuto", - "missing": "Faltando", + "missing": "Em falta", "model": "Modelo", "month": "Mês", "more": "Mais", - "moved_to_trash": "Enviado para a lixeira", - "my_albums": "Meus Álbuns", + "moved_to_trash": "Enviado para a reciclagem", + "my_albums": "Os meus álbuns", "name": "Nome", - "name_or_nickname": "Nome ou apelido", + "name_or_nickname": "Nome ou alcunha", "never": "Nunca", "new_album": "Novo Álbum", "new_api_key": "Nova Chave de API", - "new_password": "Nova senha", + "new_password": "Nova palavra-passe", "new_person": "Nova Pessoa", "new_user_created": "Novo utilizador criado", "new_version_available": "NOVA VERSÃO DISPONÍVEL", @@ -869,48 +891,48 @@ "next": "Avançar", "next_memory": "Próxima memória", "no": "Não", - "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", - "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com este nome.", - "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", + "no_albums_message": "Crie um álbum para organizar as suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", - "no_assets_message": "CLIQUE PARA CARREGAR SUA PRIMEIRA FOTO", - "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", + "no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO", + "no_duplicates_found": "Nenhum item duplicado foi encontrado.", "no_exif_info_available": "Sem informações exif disponíveis", - "no_explore_results_message": "Carregue mais fotos para explorar sua coleção.", - "no_favorites_message": "Adicione aos favoritos para encontrar suas melhores fotos e vídeos rapidamente", - "no_libraries_message": "Crie uma biblioteca externa para ver suas fotos e vídeos", + "no_explore_results_message": "Carregue mais fotos para explorar a sua coleção.", + "no_favorites_message": "Adicione aos favoritos para encontrar as suas melhores fotos e vídeos rapidamente", + "no_libraries_message": "Crie uma biblioteca externa para ver as suas fotos e vídeos", "no_name": "Sem nome", "no_places": "Sem lugares", "no_results": "Sem resultados", - "no_results_description": "Tente um sinônimo ou uma palavra-chave mais comum", - "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", - "not_in_any_album": "Fora de álbum", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", - "note_unlimited_quota": "Nota: Digite 0 para cota ilimitada", + "no_results_description": "Tente um sinónimo ou uma palavra-chave mais comum", + "no_shared_albums_message": "Crie um álbum para partilhar fotos e vídeos com pessoas na sua rede", + "not_in_any_album": "Não está em nenhum álbum", + "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", + "note_unlimited_quota": "Nota: Escreva 0 para quota ilimitada", "notes": "Notas", - "notification_toggle_setting_description": "Habilitar notificações por e-mail", + "notification_toggle_setting_description": "Ativar notificações por e-mail", "notifications": "Notificações", - "notifications_setting_description": "Gerenciar notificações", + "notifications_setting_description": "Gerir notificações", "oauth": "OAuth", "offline": "Offline", "offline_paths": "Caminhos offline", - "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", + "offline_paths_description": "Estes resultados podem ser devidos a ficheiros eliminados manualmente e que não fazem parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", - "onboarding_privacy_description": "Os seguintes recursos (opcionais) dependem de serviços externos e podem ser desabilitados a qualquer momento nas configurações de administração.", - "onboarding_theme_description": "Escolha um tema de cor para sua instância. Você pode alterar isso mais tarde em suas configurações.", - "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", + "onboarding_privacy_description": "As seguintes funcionalidades opcionais dependem de serviços externos e podem ser desativados a qualquer momento nas definições de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Pode alterar isto mais tarde nas suas definições.", + "onboarding_welcome_description": "Vamos configurar a sua instância com algumas definições comuns.", "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", - "only_favorites": "Somente favoritos", - "only_refreshes_modified_files": "Somente atualize arquivos modificados", - "open_in_map_view": "Abrir na visualização do mapa", + "only_favorites": "Apenas favoritos", + "only_refreshes_modified_files": "Apenas recarrega ficheiros modificados", + "open_in_map_view": "Abrir na visualização de mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", - "open_the_search_filters": "Abre os filtros de pesquisa", + "open_the_search_filters": "Abrir os filtros de pesquisa", "options": "Opções", "or": "ou", - "organize_your_library": "Organize sua biblioteca", + "organize_your_library": "Organizar a sua biblioteca", "original": "original", "other": "Outro", "other_devices": "Outros dispositivos", @@ -918,15 +940,15 @@ "owned": "Seu", "owner": "Dono", "partner": "Parceiro", - "partner_can_access": "{partner} pode acessar", - "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", + "partner_can_access": "{partner} pode aceder", + "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Eliminados", "partner_can_access_location": "A localização onde as fotos foram tiradas", - "partner_sharing": "Compartilhamento com Parceiro", + "partner_sharing": "Partilha com Parceiro", "partners": "Parceiros", - "password": "Senha", - "password_does_not_match": "As senhas não são iguais", - "password_required": "A senha é obrigatório", - "password_reset_success": "Senha resetada com sucesso", + "password": "Palavra-passe", + "password_does_not_match": "As palavras-passe não condizem", + "password_required": "A palavra-passe é obrigatória", + "password_reset_success": "Palavra-passe redefinida com sucesso", "past_durations": { "days": "{days, plural, one {Último dia} other {# últimos dias}}", "hours": "Últimas {hours, plural, one {horas} other {# horas}}", @@ -934,25 +956,26 @@ }, "path": "Caminho", "pattern": "Padrão", - "pause": "Interromper", - "pause_memories": "Interromper memórias", - "paused": "Interrompido", + "pause": "Pausa", + "pause_memories": "Pausar memórias", + "paused": "Em Pausa", "pending": "Pendente", "people": "Pessoas", "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", - "people_sidebar_description": "Exibe o link Pessoas na barra lateral", + "people_feature_description": "A navegar fotos e vídeos agrupados por pessoas", + "people_sidebar_description": "Exibir o link Pessoas na barra lateral", "perform_library_tasks": "", - "permanent_deletion_warning": "Aviso para deletar permanentemente", - "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", - "permanently_delete": "Deletar permanentemente", - "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", - "permanently_delete_assets_prompt": "Tem certeza que deseja excluir permanentemente {count, plural, one {esse arquivo?} other {estes # arquivos?}} Essa ação também removerá {count, plural, one {isto do} other {isto dos}} álbum(s).", - "permanently_deleted_asset": "Ativo deletado permanentemente", + "permanent_deletion_warning": "Aviso de eliminação permanente", + "permanent_deletion_warning_setting_description": "Exibir um aviso ao eliminar ficheiros de forma permanente", + "permanently_delete": "Eliminar permanentemente", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {ficheiro} other {ficheiros}}", + "permanently_delete_assets_prompt": "Tem a certeza de que deseja eliminar permanentemente {count, plural, one {este ficheiro?} other {estes # ficheiros?}} Esta ação também removerá {count, plural, one {isto do álbum} other {isto dos álbuns}}.", + "permanently_deleted_asset": "Ficheiro eliminado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", - "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", + "permanently_deleted_assets_count": "{count, plural, one {# Ficheiro eliminado} other {# Ficheiros eliminados}} permanentemente", "person": "Pessoa", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", - "photo_shared_all_users": "Parece que você compartilhou suas fotos com todos os usuários ou não tem nenhum usuário para compartilhar.", + "photo_shared_all_users": "Parece que partilhou as suas fotos com todos os utilizadores ou não tem nenhum utilizador para partilhar.", "photos": "Fotos", "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", @@ -976,179 +999,183 @@ "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", - "public_share": "Compartilhar Publicamente", - "purchase_account_info": "Apoiador", + "public_share": "Partilhar Publicamente", + "purchase_account_info": "Apoiante", "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", "purchase_activated_time": "Ativado em {date, date}", - "purchase_activated_title": "Sua chave foi ativada com sucesso", + "purchase_activated_title": "A sua chave foi ativada com sucesso", "purchase_button_activate": "Ativar", "purchase_button_buy": "Comprar", "purchase_button_buy_immich": "Comprar Immich", - "purchase_button_never_show_again": "Nunca mostrar novamente", + "purchase_button_never_show_again": "Não mostrar de novo", "purchase_button_reminder": "Relembrar-me daqui a 30 dias", "purchase_button_remove_key": "Remover chave", "purchase_button_select": "Selecionar", - "purchase_failed_activation": "Falha ao ativar! Verifique seu e-mail para obter a chave de produto correta!", - "purchase_individual_description_1": "Para uma pessoa", - "purchase_individual_description_2": "Status de apoiador", + "purchase_failed_activation": "Não foi possível ativar! Verifique o seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa individual", + "purchase_individual_description_2": "Status de apoiante", "purchase_individual_title": "Particular", "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", - "purchase_license_subtitle": "Compre Immich para apoiar o desenvolvimento contínuo do serviço", + "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar acesso pago, esta compra não lhe dará nenhum recurso adicional no Immich. Contamos com usuários como você para dar suporte ao desenvolvimento contínuo do Immich.", + "purchase_panel_info_2": "Como estamos comprometidos a não adicionar acesso pago, esta compra não lhe dará acesso a nenhuma funcionalidade adicional do Immich. Contamos com utilizadores como você para dar suporte ao desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoie o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por utilizador", "purchase_remove_product_key": "Remover chave de produto", - "purchase_remove_product_key_prompt": "Tem certeza de que deseja remover a chave do produto?", + "purchase_remove_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto?", "purchase_remove_server_product_key": "Remover chave do produto do servidor", - "purchase_remove_server_product_key_prompt": "Tem certeza de que deseja remover a chave do produto do servidor?", + "purchase_remove_server_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto do servidor?", "purchase_server_description_1": "Para o servidor inteiro", - "purchase_server_description_2": "Status de apoiador", + "purchase_server_description_2": "Status de apoiante", "purchase_server_title": "Servidor", - "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", + "purchase_settings_server_activated": "A chave de produto do servidor é gerida pelo administrador", "range": "", "rating": "Classificação por estrelas", "rating_clear": "Limpar classificação", - "rating_count": "{contar, plural, um {# estrela} outro {# estrelas}}", - "rating_description": "Exibir a classificação exif no painel de informações", + "rating_count": "{count, plural, one {# estrela} other {# estrelas}}", + "rating_description": "Mostrar a classificação EXIF no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} PARA {name, select, null {uma pessoa existente} other {{name}}}", - "reassigned_assets_to_new_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} a uma nova pessoa", - "reassing_hint": "Atribuir ativos selecionados a uma pessoa existente", - "recent": "Recente", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# ficheiro} other {# ficheiros}} para {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuído {count, plural, one {# ficheiro} other {# ficheiros}} a uma nova pessoa", + "reassing_hint": "Atribuir ficheiros selecionados a uma pessoa existente", + "recent": "Recentes", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", "refresh_encoded_videos": "Atualizar vídeos codificados", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", - "refreshes_every_file": "Atualiza todos arquivos", - "refreshing_encoded_video": "Atualizando vídeo codificado", + "refreshes_every_file": "Atualiza todos os ficheiros", + "refreshing_encoded_video": "A atualizar vídeo codificado", "refreshing_metadata": "A atualizar metadados", "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", - "remove_assets_album_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} do álbum?", - "remove_assets_shared_link_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", - "remove_assets_title": "Remover arquivos?", + "remove_assets_album_confirmation": "Tem a certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} deste link partilhado?", + "remove_assets_title": "Remover ficheiros?", "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", - "remove_from_shared_link": "Remover do link compartilhado", - "remove_offline_files": "Remover arquivos offline", + "remove_from_shared_link": "Remover do link partilhado", + "remove_offline_files": "Remover ficheiros offline", "remove_user": "Remover utilizador", - "removed_api_key": "Removido a Chave de API: {name}", + "removed_api_key": "Foi removida a Chave de API: {name}", "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", - "removed_from_favorites_count": "{count, plural, other {Removido #}} dos favoritos", - "rename": "Renomear", + "removed_from_favorites_count": "{count, plural, other {Removidos #}} dos favoritos", + "removed_tagged_assets": "Removida a etiqueta de {count, plural, one {# ficheiro} other {# ficheiros}}", + "rename": "Mudar o nome", "repair": "Reparar", - "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", - "replace_with_upload": "Substituir", + "repair_no_results_message": "Ficheiros perdidos ou não rastreados irão aparecer aqui", + "replace_with_upload": "Substituir pelo ficheiro carregado", "repository": "Repositório", - "require_password": "Proteger com senha", - "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a senha após primeiro início de sessão", - "reset": "Resetar", - "reset_password": "Resetar senha", - "reset_people_visibility": "Resetar pessoas ocultas", + "require_password": "Proteger com palavra-passe", + "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a palavra-passe após o primeiro início de sessão", + "reset": "Redefinir", + "reset_password": "Redefinir palavra-passe", + "reset_people_visibility": "Redefinir pessoas ocultas", "reset_settings_to_default": "", "reset_to_default": "Repor predefinições", "resolve_duplicates": "Resolver itens duplicados", - "resolved_all_duplicates": "Todas duplicidades resolvidas", + "resolved_all_duplicates": "Todos os itens duplicados resolvidos", "restore": "Restaurar", "restore_all": "Restaurar tudo", "restore_user": "Restaurar utilizador", - "restored_asset": "Arquivo restaurado", + "restored_asset": "Ficheiro restaurado", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", - "review_duplicates": "Revisar duplicidade", + "review_duplicates": "Rever itens duplicados", "role": "Função", "role_editor": "Editor", "role_viewer": "Visualizador", "save": "Guardar", - "saved_api_key": "Chave de API salva", - "saved_profile": "Perfil Salvo", - "saved_settings": "Configurações salvas", - "say_something": "Diga algo", - "scan_all_libraries": "Escanear Todas Bibliotecas", - "scan_all_library_files": "Re-escanear todos arquivos da biblioteca", - "scan_new_library_files": "Escanear novos arquivos na biblioteca", - "scan_settings": "Opções de escanear", - "scanning_for_album": "Escaneando por álbum...", + "saved_api_key": "Chave de API guardada", + "saved_profile": "Perfil guardado", + "saved_settings": "Definições guardadas", + "say_something": "Diga alguma coisa", + "scan_all_libraries": "Analisar todas as bibliotecas", + "scan_all_library_files": "Re-analisar todos os ficheiros da biblioteca", + "scan_new_library_files": "Analisar novos ficheiros na biblioteca", + "scan_settings": "Opções de análise", + "scanning_for_album": "A analisar por álbum...", "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", - "search_camera_make": "Pesquisar câmeras da marca...", - "search_camera_model": "Pesquisar câmera do modelo...", + "search_camera_make": "Pesquisar por marca da câmara...", + "search_camera_model": "Pesquisar por modelo da câmara...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", - "search_for_existing_person": "Pesquisar por pessoas", - "search_no_people": "Nenhuma pessoa", + "search_for_existing_person": "Pesquisar por pessoas existentes", + "search_no_people": "Sem pessoas", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", + "search_options": "Opções de pesquisa", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", - "search_state": "Pesquisar estado...", + "search_settings": "Definições de pesquisa", + "search_state": "Pesquisar estado/distrito...", + "search_tags": "Pesquisar etiquetas...", "search_timezone": "Pesquisar fuso horário...", - "search_type": "Pesquisar tipo", + "search_type": "Tipo de pesquisa", "search_your_photos": "Pesquisar fotos", - "searching_locales": "Pesquisar Lugares....", + "searching_locales": "A pesquisar Lugares....", "second": "Segundo", "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_all_duplicates": "Selecionar todos os itens duplicados", "select_avatar_color": "Selecionar cor do avatar", - "select_face": "Selecionar face", + "select_face": "Selecionar rosto", "select_featured_photo": "Selecionar foto principal", - "select_from_computer": "Selecionar do computador", - "select_keep_all": "Marcar manter em todos", - "select_library_owner": "Selecione o dono da biblioteca", - "select_new_face": "Selecionar nova face", + "select_from_computer": "Selecionar a partir do computador", + "select_keep_all": "Selecionar manter todos", + "select_library_owner": "Selecionar o dono da biblioteca", + "select_new_face": "Selecionar novo rosto", "select_photos": "Selecionar fotos", - "select_trash_all": "Marcar lixo em todos", + "select_trash_all": "Selecionar todos para reciclagem", "selected": "Selecionados", - "selected_count": "{count, plural, other {# selecionado}}", + "selected_count": "{count, plural, other {# selecionados}}", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", "server_offline": "Servidor Offline", "server_online": "Servidor Online", - "server_stats": "Status do servidor", + "server_stats": "Estado do servidor", "server_version": "Versão do servidor", "set": "Definir", "set_as_album_cover": "Definir como capa do álbum", "set_as_profile_picture": "Definir como foto de perfil", "set_date_of_birth": "Definir data de nascimento", "set_profile_picture": "Definir foto de perfil", - "set_slideshow_to_fullscreen": "Apresentação em tela cheia", - "settings": "Configurações", - "settings_saved": "Configurações salvas", - "share": "Compartilhar", - "shared": "Compartilhado", - "shared_by": "Compartilhado por", + "set_slideshow_to_fullscreen": "Apresentação em ecrã inteiro", + "settings": "Definições", + "settings_saved": "Definições guardadas", + "share": "Partilhar", + "shared": "Partilhado", + "shared_by": "Partilhado por", "shared_by_user": "Partilhado por {user}", - "shared_by_you": "Compartilhado por você", + "shared_by_you": "Partilhado por si", "shared_from_partner": "Fotos de {partner}", - "shared_link_options": "Opções de link compartilhado", - "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos compartilhados.}}", - "shared_with_partner": "Compartilhado com {partner}", - "sharing": "Compartilhar", - "sharing_enter_password": "Por favor, digite a senha para visualizar esta página.", - "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", - "shift_to_permanent_delete": "Pressione ⇧ para excluir o arquivo permanentemente", + "shared_link_options": "Opções de link partilhado", + "shared_links": "Links partilhados", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", + "shared_with_partner": "Partilhado com {partner}", + "sharing": "Partilha", + "sharing_enter_password": "Por favor, insira a palavra-passe para ver esta página.", + "sharing_sidebar_description": "Exibe o link para Partilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para eliminar o ficheiro permanentemente", "show_album_options": "Exibir opções do álbum", "show_albums": "Mostrar álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", - "show_file_location": "Exibir local do arquivo", + "show_file_location": "Exibir localização do ficheiro", "show_gallery": "Exibir galeria", "show_hidden_people": "Exibir pessoas ocultadas", "show_in_timeline": "Exibir na linha do tempo", @@ -1156,19 +1183,23 @@ "show_keyboard_shortcuts": "Exibir atalhos do teclado", "show_metadata": "Mostrar metadados", "show_or_hide_info": "Exibir ou ocultar informações", - "show_password": "Exibir senha", + "show_password": "Mostrar palavra-passe", "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", - "show_supporter_badge": "Emblema de apoiador", - "show_supporter_badge_description": "Mostrar um emblema de apoiador", + "show_supporter_badge": "Emblema de apoiante", + "show_supporter_badge_description": "Mostrar um emblema de apoiante", "shuffle": "Aleatório", - "sign_out": "Sair", - "sign_up": "Registrar", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostrar um link para a vista na barra lateral", + "sign_out": "Terminar sessão", + "sign_up": "Criar conta", "size": "Tamanho", - "skip_to_content": "Pular para o conteúdo", + "skip_to_content": "Saltar para o conteúdo", + "skip_to_folders": "Saltar para pastas", + "skip_to_tags": "Saltar para as etiquetas", "slideshow": "Apresentação", - "slideshow_settings": "Opções de apresentação", + "slideshow_settings": "Definições de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", "sort_items": "Número de itens", @@ -1178,49 +1209,58 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", - "stack_duplicates": "Empilhar duplicados", + "stack_duplicates": "Empilhar itens duplicados", "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", - "stacked_assets_count": "Empilhado {count, plural, one {# arquivo} other {# arquivos}}", + "stacked_assets_count": "Empilhado {count, plural, one {# ficheiro} other {# ficheiros}}", "stacktrace": "Stacktrace", - "start": "Início", - "start_date": "Data inicial", + "start": "Iniciar", + "start_date": "Data de início", "state": "Estado", - "status": "Status", + "status": "Estado", "stop_motion_photo": "Parar foto em movimento", - "stop_photo_sharing": "Parar de partilhar as suas fotos?", - "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", - "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este utilizador", + "stop_photo_sharing": "Deixar de partilhar as suas fotos?", + "stop_photo_sharing_description": "{partner} deixará de ter acesso às suas fotos.", + "stop_sharing_photos_with_user": "Deixar de partilhar as fotos com este utilizador", "storage": "Espaço de armazenamento", - "storage_label": "Rótulo de armazenamento", - "storage_usage": "utilizado {used} de {available}", + "storage_label": "Rótulo de Armazenamento", + "storage_usage": "Utilizado {used} de {available}", "submit": "Enviar", "suggestions": "Sugestões", "sunrise_on_the_beach": "Nascer do sol na praia", - "swap_merge_direction": "Alternar direção da mesclagem", + "swap_merge_direction": "Alternar direção da união", "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar ficheiros", + "tag_created": "Criada a etiqueta {tag}", + "tag_feature_description": "A mostrar fotos e videos agrupados por tópicos lógicos de etiquetas", + "tag_not_found_question": "Não consegue encontrar a etiqueta? Crie uma aqui", + "tag_updated": "Atualizada a etiqueta: {tag}", + "tagged_assets": "Etiquetado {count, plural, one {# ficheiros} other {# ficheiros}}", + "tags": "Etiquetas", "template": "Modelo", "theme": "Tema", "theme_selection": "Selecionar tema", - "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", - "they_will_be_merged_together": "Eles serão mesclados", - "time_based_memories": "Memórias baseada no tempo", + "theme_selection_description": "Definir automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão unidos", + "time_based_memories": "Memórias baseadas no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", - "to_change_password": "Alterar senha", + "to_change_password": "Alterar palavra-passe", "to_favorite": "Favorito", - "to_login": "Iniciar sessão", - "to_trash": "Lixo", + "to_login": "Iniciar Sessão", + "to_parent": "Ir para o pai", + "to_trash": "Reciclagem", "toggle_settings": "Alternar configurações", - "toggle_theme": "Alternar tema", + "toggle_theme": "Ativar modo escuro", "toggle_visibility": "Alternar visibilidade", "total_usage": "Total utilizado", - "trash": "Lixeira", - "trash_all": "Todos para o lixo", - "trash_count": "Lixeira {count, number}", - "trash_delete_asset": "Excluir arquivo", - "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", - "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", + "trash": "Reciclagem", + "trash_all": "Mover todos para a reciclagem", + "trash_count": "Reciclar {count, number}", + "trash_delete_asset": "Eliminar ficheiro", + "trash_no_results_message": "Fotos e vídeos enviados para a reciclagem aparecem aqui.", + "trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem são eliminados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", "unarchived": "Restaurado do arquivo", @@ -1231,70 +1271,72 @@ "unknown_album": "", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", + "unlink_motion_video": "Remover relação com video animado", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", - "unnamed_album_delete_confirmation": "Tem a certeza que pretende remover este album?", - "unnamed_share": "Compartilhamento sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza de que pretende eliminar este álbum?", + "unnamed_share": "Partilha sem nome", "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Remover seleção de todos os duplicados", + "unselect_all_duplicates": "Remover seleção de todos os itens duplicados", "unstack": "Desempilhar", - "unstacked_assets_count": "Desempilhar {count, plural, one {# arquivo} other {# arquivos}}", - "untracked_files": "Arquivos não monitorados", - "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", + "unstacked_assets_count": "Desempilhados {count, plural, one {# ficheiro} other {# ficheiros}}", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_decription": "Estes ficheiros não são monitorizados pela aplicação. Podem ser resultados de falhas numa movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", - "updated_password": "Senha atualizada", + "updated_password": "Palavra-passe atualizada", "upload": "Carregar", - "upload_concurrency": "Carregar simultâneo", - "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver novos arquivos enviados.", + "upload_concurrency": "Carregamentos em simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", - "upload_skipped_duplicates": "Ignorado {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}", + "upload_skipped_duplicates": "{count, plural, one {# Ignorado ficheiro duplicado} other {# Ignorados ficheiros duplicados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", "upload_status_uploaded": "Enviado", - "upload_success": "Upload realizado com sucesso, atualize a página para ver os novos ativos de upload.", + "upload_success": "Carregamento realizado com sucesso, atualize a página para ver os novos ficheiros carregados.", "url": "URL", - "usage": "Uso", - "use_custom_date_range": "Usar um intervalo de datas personalizado", + "usage": "Utilização", + "use_custom_date_range": "Utilizar um intervalo de datas personalizado", "user": "Utilizador", "user_id": "ID do utilizador", - "user_liked": "{user} gostou {type, select, photo {dessa foto} video {deste video} asset {deste arquivo} other {disto}}", - "user_purchase_settings": "Compra", - "user_purchase_settings_description": "Gerencie sua compra", + "user_liked": "{user} gostou {type, select, photo {desta fotografia} video {deste video} asset {deste ficheiro} other {disto}}", + "user_purchase_settings": "Comprar", + "user_purchase_settings_description": "Gerir a sua compra", "user_role_set": "Definir {user} como {role}", - "user_usage_detail": "Detalhes de uso do utilizador", - "username": "Nome do utilizador", + "user_usage_detail": "Detalhes de utilização do utilizador", + "username": "Nome de utilizador", "users": "Utilizadores", - "utilities": "Utilitários", + "utilities": "Ferramentas", "validate": "Validar", "variables": "Variáveis", "version": "Versão", - "version_announcement_closing": "Seu amigo, Alex", - "version_announcement_message": "Olá amigo, há uma nova versão do aplicativo. Reserve um tempo para visitar as histórico de mudanças e garantir que suas configurações docker-compose.yml e .env estejam atualizadas para evitar qualquer configuração incorreta, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização do seu aplicativo automaticamente.", + "version_announcement_closing": "O seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão da aplicação. Reserve algum tempo para visitar o histórico de mudanças e garantir que as suas configurações do docker-compose.yml e .env estão atualizadas para evitar qualquer configuração incorreta, especialmente se usar o WatchTower ou qualquer mecanismo que lide com a atualização automática da aplicação.", "video": "Vídeo", - "video_hover_setting": "Reproduzir vídeo em miniatura quando passar por cima", - "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o mouse está sobre o item. Mesmo quando desativado, a reprodução ainda pode ser iniciada passando sobre o ícone.", + "video_hover_setting": "Reproduzir vídeo em miniatura quando passar com o cursor por cima", + "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o cursor está sobre o item. Mesmo quando está desativado, a reprodução ainda pode ser iniciada passando sobre o ícone de reproduzir.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", "view_album": "Ver Álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos os utilizadores", + "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", - "view_next_asset": "Ver próximo ativo", - "view_previous_asset": "Ver ativo anterior", - "view_stack": "Visualizar pilha", + "view_next_asset": "Ver próximo ficheiro", + "view_previous_asset": "Ver ficheiro anterior", + "view_stack": "Ver pilha", "viewer": "Visualizar", "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", - "waiting": "Aguardando", + "waiting": "Em fila", "warning": "Aviso", "week": "Semana", - "welcome": "Bem-vindo", - "welcome_to_immich": "Bem-vindo ao Immich", + "welcome": "Bem-vindo(a)", + "welcome_to_immich": "Bem-vindo(a) ao Immich", "year": "Ano", - "years_ago": "Há {years, plural, one {# ano} other {# anos}}", + "years_ago": "Há {years, plural, one {# ano} other {# anos}} atrás", "yes": "Sim", - "you_dont_have_any_shared_links": "Não há links compartilhados", - "zoom_image": "Ampliar imagem" + "you_dont_have_any_shared_links": "Não tem links partilhados", + "zoom_image": "Ampliar/Reduzir imagem" } diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 02022569cd..195c33c943 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pentru a confirma, tastați „{email}” mai jos", "confirm_reprocess_all_faces": "Sigur doriți să reprocesați toate fețele? Acest lucru va șterge și persoanele cu nume.", "confirm_user_password_reset": "Sigur doriți să resetați parola utilizatorului {user}?", + "create_job": "Creează sarcină", "crontab_guru": "", "disable_login": "Dezactivați autentificarea", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezoluție imagini miniatură", "image_thumbnail_resolution_description": "Folosit la vizualizarea unor grupuri de fotografii (cronologie principală, vizualizare album etc.). Rezoluțiile mai mari pot păstra mai multe detalii, dar codarea durează mai mult, au dimensiuni mai mari ale fișierelor și pot reduce capacitatea de răspuns a aplicației.", "job_concurrency": "concurență {job}", + "job_created": "Sarcină creată", "job_not_concurrency_safe": "Acest job nu este sigur pentru a rula în concurență.", "job_settings": "Setări sarcină", "job_settings_description": "Administrează concurența sarcinilor", @@ -238,6 +240,7 @@ "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", "system_settings": "Setǎri de sistem", + "tag_cleanup_job": "Curățare etichete", "theme_custom_css_settings": "CSS personalizat", "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", "theme_settings": "Setări temă", @@ -273,7 +276,7 @@ "transcoding_hardware_decoding": "Decodare hardware", "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", "transcoding_hevc_codec": "codec HEVC", - "transcoding_max_b_frames": "", + "transcoding_max_b_frames": "Număr maxim de cadre B", "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", "transcoding_max_bitrate": "Bitrate maxim", "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", @@ -312,6 +315,7 @@ "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", "untracked_files": "Fișiere neurmărite", "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_cleanup_job": "Curățare utilizator", "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", "user_delete_delay_settings": "Întârziere la ștergere", "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", @@ -338,6 +342,7 @@ "advanced": "Avansat", "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", + "age_years": "{years, plural, other {Vârstă #}}", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", "album_cover_updated": "Coperta albumului a fost actualizată", @@ -561,10 +566,15 @@ "edit_location": "Editează locație", "edit_name": "Editează nume", "edit_people": "Editează persoane", + "edit_tag": "Modifică etichetă", "edit_title": "Editează Titlul", - "edit_user": "", + "edit_user": "Modifică utilizator", "edited": "Editat", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Schimbările nu vor fi salvate", + "editor_close_without_save_title": "Închizi editorul?", + "editor_crop_tool_h2_aspect_ratios": "Raporturi de aspect", + "editor_crop_tool_h2_rotation": "Rotire", "email": "Email", "empty": "", "empty_album": "", @@ -580,7 +590,9 @@ "cannot_navigate_next_asset": "Nu se poate naviga către următorul activ", "cannot_navigate_previous_asset": "Nu se poate naviga la activul anterior", "cant_apply_changes": "Nu se pot aplica schimbări", + "cant_change_activity": "Nu se poate {enabled, select, true {dezactiva} other {activa}} activitatea", "cant_change_asset_favorite": "Nu pot schimba favoritul pentru activ", + "cant_change_metadata_assets_count": "Nu se pot modifica metadatele pentru {count, plural, one {# element} other {# elemente}}", "cant_get_faces": "Nu pot obține fețe", "cant_get_number_of_comments": "Nu pot obține numărul de comentarii", "cant_search_people": "Nu pot căuta oameni", @@ -598,12 +610,34 @@ "failed_to_create_album": "A eșuat crearea albumului", "failed_to_create_shared_link": "A eșuat crearea legăturii partajate", "failed_to_edit_shared_link": "A eșuat editarea legăturii partajate", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "failed_to_get_people": "Eșec la obținerea persoanelor", + "failed_to_load_asset": "Eșec la încărcarea resursei", + "failed_to_load_assets": "Eșec la încărcarea resurselor", + "failed_to_load_people": "Eșec la încărcarea oamenilor", + "failed_to_remove_product_key": "Eșec la eliminarea cheii de produs", + "failed_to_stack_assets": "Eșec la combinarea resurselor", + "failed_to_unstack_assets": "Eșec la desfășurarea resurselor", + "import_path_already_exists": "Această cale de import există deja.", + "incorrect_email_or_password": "E-mail sau parolă incorect/ă", + "paths_validation_failed": "{paths, plural, one {# cale} other {# căi}} nu a trecut validarea", + "profile_picture_transparent_pixels": "Pozele de profil nu pot avea pixeli transparenți. Te rugăm să mărești imaginea și/sau să o muți.", + "quota_higher_than_disk_size": "Ai stabilit o cotă mai mare decât dimensiunea discului", + "repair_unable_to_check_items": "Imposibil de verificat {count, select, one {element} other {elemente}}", + "unable_to_add_album_users": "Imposibil de adăugat utilizatori în album", + "unable_to_add_assets_to_shared_link": "Imposibil de adăugat resurse la link-ul partajat", + "unable_to_add_comment": "Imposibil de adăugat comentariu", + "unable_to_add_exclusion_pattern": "Nu se poate adăuga modelul de excluziune", + "unable_to_add_import_path": "Imposibil de adăugat calea de import", + "unable_to_add_partners": "Nu se poate de adăuga parteneri", + "unable_to_add_remove_archive": "Nu se poate {archived, select, true {îndepărta resursa din} other {adăuga resursa în}} arhivă", + "unable_to_add_remove_favorites": "Nu se poate {favorite, select, true {adăuga resursa în} other {îndepărta resursa din}} favorite", + "unable_to_archive_unarchive": "Nu se poate {archived, select, true {arhiva} other {dezarhiva}}", + "unable_to_change_album_user_role": "Nu se poate schimba rolul utilizatorului de album", + "unable_to_change_date": "Imposibil de schimbat data", + "unable_to_change_favorite": "Nu se poate modifica favoritele pentru resursă", + "unable_to_change_location": "Imposibil de schimbat locația", + "unable_to_change_password": "Imposibil de schimbat parola", + "unable_to_change_visibility": "Nu se poate schimba vizibilitatea pentru {count, plural, one {# persoană} other {# persoane}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_create_admin_account": "", @@ -625,22 +659,26 @@ "unable_to_remove_album_users": "", "unable_to_remove_comment": "", "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_offline_files": "Nu se pot șterge fișierele offline", + "unable_to_remove_partner": "Imposibil de eliminat partenerul", + "unable_to_remove_reaction": "Nu se poate elimina reația", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", + "unable_to_repair_items": "Imposibil de a repara elementele", + "unable_to_reset_password": "Imposibil de a reseta parola", + "unable_to_resolve_duplicate": "Nu se poate de rezolvat duplicatul", + "unable_to_restore_assets": "Nu se pot restaura resursele", + "unable_to_restore_trash": "Nu se poate restaura coșul de gunoi", + "unable_to_restore_user": "Nu se poate restaura utilizatorul", + "unable_to_save_album": "Imposibil de salvat albumul", + "unable_to_save_api_key": "Imposibil de salvat cheia API", + "unable_to_save_date_of_birth": "Imposibil de a salva data de naștere", + "unable_to_save_name": "Imposibil de a salva numele", + "unable_to_save_profile": "Imposibil de a salva profilul", + "unable_to_save_settings": "Nu se pot salva setările", + "unable_to_scan_libraries": "Nu se pot scana librăriile", + "unable_to_scan_library": "Nu se poate de scanat librăria", + "unable_to_set_feature_photo": "Nu se poate seta fotografia principală", + "unable_to_set_profile_picture": "Nu se poate seta fotografia de profil", "unable_to_submit_job": "", "unable_to_trash_asset": "", "unable_to_unlink_account": "", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 44b9e48f95..c6d4cf9481 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -41,6 +41,7 @@ "confirm_email_below": "Чтобы подтвердить, введите \"{email}\" ниже", "confirm_reprocess_all_faces": "Вы уверены, что хотите повторно определить все лица? Будут также удалены имена со всех лиц.", "confirm_user_password_reset": "Вы уверены, что хотите сбросить пароль пользователя {user}?", + "create_job": "Создать задание", "crontab_guru": "Crontab Guru", "disable_login": "Отключить вход", "disabled": "Выключено", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Разрешение миниатюр", "image_thumbnail_resolution_description": "Используется при просмотре групп фотографий (на временной шкале, при просмотре альбомов и т.д.). Миниатюры с более высоким разрешением сохраняют больше деталей, но требуют больше времени для кодирования, имеют больший вес и могут снизить скорость отклика приложения.", "job_concurrency": "Параллельная обработка задания - {job}", + "job_created": "Задание создано", "job_not_concurrency_safe": "Эта задача не обеспечивает безопасность параллельности выполнения.", "job_settings": "Настройки заданий", "job_settings_description": "Управление параллельной обработкой заданий", @@ -198,6 +200,7 @@ "password_settings": "Настройки входа с паролем", "password_settings_description": "Управление настройками входа по паролю", "paths_validated_successfully": "Все пути успешно прошли проверку", + "person_cleanup_job": "Очистка персоны", "quota_size_gib": "Размер квоты (ГБ)", "refreshing_all_libraries": "Обновление всех библиотек", "registration": "Регистрация Администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", "scanning_library_for_changed_files": "Поиск измененных файлов", "scanning_library_for_new_files": "Поиск новых файлов", + "search_jobs": "Поиск заданий...", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для общедоступных ссылок, включая http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управление структурой папок и именем загружаемого файла", "storage_template_user_label": "{label} - это метка хранилища пользователя", "system_settings": "Системные настройки", + "tag_cleanup_job": "Очистка тега", "theme_custom_css_settings": "Пользовательские CSS", "theme_custom_css_settings_description": "Каскадные таблицы стилей позволяют настраивать дизайн Immich.", "theme_settings": "Настройки темы", @@ -312,6 +317,7 @@ "trash_settings_description": "Управление настройками корзины", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_description": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", + "user_cleanup_job": "Очистка пользователя", "user_delete_delay": "Аккаунт и ресурсы пользователя {user} будут запланированы для окончательного удаления через {delay, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", "user_delete_delay_settings": "Отложенное удаление", "user_delete_delay_settings_description": "Срок в днях, по истечение которого происходит окончательное удаление учетной записи пользователя и его ресурсов после удаления учётной записи. Задача по удалению пользователей выполняется в полночь. Изменения этой настройки будут учтены при следующем запуске задачи.", @@ -1141,6 +1147,7 @@ "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", + "search_settings": "Настройки поиска", "search_state": "Поиск региона...", "search_tags": "Поиск по тегам...", "search_timezone": "Поиск часового пояса...", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 1241ad72fe..b9908b78f0 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Да бисте потврдили, унесите \"{email}\" испод", "confirm_reprocess_all_faces": "Да ли сте сигурни да желите да поново обрадите сва лица? Ово ће такође обрисати именоване особе.", "confirm_user_password_reset": "Да ли сте сигурни да желите да ресетујете лозинку корисника {user}?", + "create_job": "Креирајте посао", "crontab_guru": "Guru servisnih zadataka", "disable_login": "oneмогући пријаву", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Резолуција сличице", "image_thumbnail_resolution_description": "Користи се приликом прегледа група фотографија (главна временска линија, приказ албума, итд.). Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање брзину апликације.", "job_concurrency": "{job} паралелност", + "job_created": "Посао креиран", "job_not_concurrency_safe": "Овај посао није безбедан да буде паралелно активан.", "job_settings": "Подешавања посла", "job_settings_description": "Управљајте паралелношћу послова", @@ -198,6 +200,7 @@ "password_settings": "Лозинка за пријаву", "password_settings_description": "Управљајте подешавањима за пријаву лозинком", "paths_validated_successfully": "Све путање су успешно потврђене", + "person_cleanup_job": "Чишћење особа", "quota_size_gib": "Величина квоте (ГиБ)", "refreshing_all_libraries": "Освежавање свих библиотека", "registration": "Регистрација администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", "scanning_library_for_changed_files": "Скенирање библиотеке за промењене датотеке", "scanning_library_for_new_files": "Скенирање библиотеке за нове датотеке", + "search_jobs": "Тражи послове...", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управљајте структуром директоријума и именом датотеке средства за отпремање", "storage_template_user_label": "{label} је ознака за складиштење корисника", "system_settings": "Подешавања система", + "tag_cleanup_job": "Чишћење ознака (tags)", "theme_custom_css_settings": "Прилагођени CSS", "theme_custom_css_settings_description": "Каскадни листови стилова (CSS) омогућавају прилагођавање дизајна Immich-a.", "theme_settings": "Подешавање тема", @@ -312,6 +317,7 @@ "trash_settings_description": "Управљајте подешавањима смећа", "untracked_files": "Непраћене датотеке", "untracked_files_description": "Апликација не прати ове датотеке. one могу настати због неуспешних премештења, због прекинутих отпремања или као преостатак због грешке", + "user_cleanup_job": "Чишћење корисника", "user_delete_delay": "Налог и датотеке {user} биће заказани за трајно брисање за {delay, plural, one {# дан} other {# дана}}.", "user_delete_delay_settings": "Избриши уз кашњење", "user_delete_delay_settings_description": "Број дана након уклањања за трајно брисање корисничког налога и датотека. Посао брисања корисника се покреће у поноћ да би се проверили корисници који су спремни за брисање. Промене ове поставке ће бити процењене при следећем извршењу.", @@ -1141,6 +1147,7 @@ "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", + "search_settings": "Претрага подешавања", "search_state": "Тражи регион...", "search_tags": "Претражи ознаке (tags)...", "search_timezone": "Претражи временску зону...", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 26f5483c69..9a32824835 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -41,6 +41,7 @@ "confirm_email_below": "Da biste potvrdili, unesite \"{email}\" ispod", "confirm_reprocess_all_faces": "Da li ste sigurni da želite da ponovo obradite sva lica? Ovo će takođe obrisati imenovane osobe.", "confirm_user_password_reset": "Da li ste sigurni da želite da resetujete lozinku korisnika {user}?", + "create_job": "Kreirajte posao", "crontab_guru": "Guru servisnih zadataka", "disable_login": "Onemogući prijavu", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezolucija sličice", "image_thumbnail_resolution_description": "Koristi se prilikom pregleda grupa fotografija (glavna vremenska linija, prikaz albuma, itd.). Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje brzinu aplikacije.", "job_concurrency": "{job} paralelnost", + "job_created": "Posao kreiran", "job_not_concurrency_safe": "Ovaj posao nije bezbedan da bude paralelno aktivan.", "job_settings": "Podešavanja posla", "job_settings_description": "Upravljajte paralelnošću poslova", @@ -198,6 +200,7 @@ "password_settings": "Lozinka za prijavu", "password_settings_description": "Upravljajte podešavanjima za prijavu lozinkom", "paths_validated_successfully": "Sve putanje su uspešno potvrđene", + "person_cleanup_job": "Čišćenje osoba", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvežavanje svih biblioteka", "registration": "Registracija administratora", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Resetujte podešavanja na nedavno sačuvana podešavanja", "scanning_library_for_changed_files": "Skeniranje biblioteke za promenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži poslove...", "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Upravljajte strukturom direktorijuma i imenom datoteke sredstva za otpremanje", "storage_template_user_label": "{label} je oznaka za skladištenje korisnika", "system_settings": "Podešavanja sistema", + "tag_cleanup_job": "Čišćenje oznaka (tags)", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućavaju prilagođavanje dizajna Immich-a.", "theme_settings": "Podešavanje tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Upravljajte podešavanjima smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. One mogu nastati zbog neuspešnih premeštenja, zbog prekinutih otpremanja ili kao preostatak zbog greške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Nalog i datoteke {user} biće zakazani za trajno brisanje za {delay, plural, one {# dan} other {# dana}}.", "user_delete_delay_settings": "Izbriši uz kašnjenje", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog naloga i datoteka. Posao brisanja korisnika se pokreće u ponoć da bi se proverili korisnici koji su spremni za brisanje. Promene ove postavke će biti procenjene pri sledećem izvršenju.", @@ -702,7 +708,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", - "unable_to_unlink_motion_video": "Nije moguće odvezati video sa slikom", + "unable_to_unlink_motion_video": "Nije moguće odvezati video od slike", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -1141,6 +1147,7 @@ "search_options": "Opcije pretrage", "search_people": "Pretraži osobe", "search_places": "Pretraži mesta", + "search_settings": "Pretraga podešavanja", "search_state": "Traži region...", "search_tags": "Pretraži oznake (tags)...", "search_timezone": "Pretraži vremensku zonu...", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 3e24ccacc4..5c55b6fe83 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -41,6 +41,7 @@ "confirm_email_below": "Для підтвердження введіть \"{email}\" нижче", "confirm_reprocess_all_faces": "Ви впевнені, що хочете повторно визначити всі обличчя? Це також призведе до видалення імен з усіх облич.", "confirm_user_password_reset": "Ви впевнені, що хочете скинути пароль користувача {user}?", + "create_job": "Створити завдання", "crontab_guru": "", "disable_login": "Вимкнути вхід", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Розмір ескізу", "image_thumbnail_resolution_description": "Використовується при перегляді груп фотографій (основна стрічка, перегляд альбому тощо). Вища роздільна здатність може зберегти більше деталей, але вимагає більше часу для кодування, має більший розмір файлів і може знижувати чутливість додатку.", "job_concurrency": "{job} одночасно", + "job_created": "Завдання створено", "job_not_concurrency_safe": "Це завдання не є безпечним для одночасного виконання.", "job_settings": "Налаштування завдань", "job_settings_description": "Управління паралельністю завдань", @@ -198,6 +200,7 @@ "password_settings": "Налаштування входу з паролем", "password_settings_description": "Керування налаштуваннями входу за паролем", "paths_validated_successfully": "Усі шляхи успішно перевірено", + "person_cleanup_job": "Очищення особи", "quota_size_gib": "Розмір квоти (GiB)", "refreshing_all_libraries": "Оновлення всіх бібліотек", "registration": "Реєстрація адміністратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", "scanning_library_for_changed_files": "Сканування бібліотеки на наявність змінених файлів", "scanning_library_for_new_files": "Сканування бібліотеки на наявність нових файлів", + "search_jobs": "Пошук завдань...", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Керуйте структурою тек та іменем завантаженого файлу", "storage_template_user_label": "{label} - це мітка зберігання користувача", "system_settings": "Системні налаштування", + "tag_cleanup_job": "Очистити тег", "theme_custom_css_settings": "Власний CSS", "theme_custom_css_settings_description": "Каскадні таблиці стилів дозволяють настроювати дизайн Immich.", "theme_settings": "Налаштування теми", @@ -312,6 +317,7 @@ "trash_settings_description": "Керування налаштуваннями кошика", "untracked_files": "Невідстежувані файли", "untracked_files_description": "Ці файли не відстежуються програмою. Вони можуть бути результатом невдалого переміщення, перерваного завантаження або залишитися через помилку програми", + "user_cleanup_job": "Очищення користувача", "user_delete_delay": "Акаунт {user} і його ресурси будуть заплановані для остаточного видалення через {delay, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", "user_delete_delay_settings": "Видалити затримку", "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", @@ -1139,6 +1145,7 @@ "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", + "search_settings": "Налаштування пошуку", "search_state": "Пошук регіону...", "search_tags": "Пошук тегів...", "search_timezone": "Пошук часового поясу...", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index ec8c8d4e7f..405c5da442 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -41,6 +41,7 @@ "confirm_email_below": "Để xác nhận, nhập \"{email}\" bên dưới", "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", + "create_job": "Tạo tác vụ", "crontab_guru": "Crontab Guru", "disable_login": "Vô hiệu hoá đăng nhập", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Độ phân giải ảnh thu nhỏ", "image_thumbnail_resolution_description": "Dùng khi xem một nhóm các ảnh (dòng thời gian chính, xem album, v.v.). Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", "job_concurrency": "{job} thực hiện đồng thời", + "job_created": "Tác vụ đã được tạo", "job_not_concurrency_safe": "Tác vụ này không an toàn để thực hiện đồng thời.", "job_settings": "Tác vụ", "job_settings_description": "Quản lý mức độ thực hiện đồng thời của tác vụ", @@ -198,6 +200,7 @@ "password_settings": "Mật khẩu đăng nhập", "password_settings_description": "Quản lý cài đặt mật khẩu đăng nhập", "paths_validated_successfully": "Tất cả các đường dẫn được xác minh thành công", + "person_cleanup_job": "Dọn dẹp người", "quota_size_gib": "Hạn mức (GiB)", "refreshing_all_libraries": "Làm mới tất cả các thư viện", "registration": "Đăng ký Quản trị viên", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Đặt lại cài đặt về cài đặt trước đó", "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tập tin đã thay đổi", "scanning_library_for_new_files": "Đang quét thư viện để tìm các tập tin mới", + "search_jobs": "Tìm kiếm tác vụ...", "send_welcome_email": "Gửi email chào mừng", "server_external_domain_settings": "Tên miền công khai", "server_external_domain_settings_description": "Tên miền dành cho các liên kết chia sẻ công khai, bao gồm http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tập tin của ảnh tải lên", "storage_template_user_label": "Cụm từ {label} là Nhãn lưu trữ của người dùng", "system_settings": "Cài đặt hệ thống", + "tag_cleanup_job": "Dọn dẹp thẻ", "theme_custom_css_settings": "CSS tùy chỉnh", "theme_custom_css_settings_description": "Cascading Style Sheets cho phép tùy chỉnh thiết kế của Immich.", "theme_settings": "Chủ đề", @@ -312,6 +317,7 @@ "trash_settings_description": "Quản lý cài đặt thùng rác", "untracked_files": "Các tập tin không được theo dõi", "untracked_files_description": "Những tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", + "user_cleanup_job": "Dọn dẹp người dùng", "user_delete_delay": "Tài khoản và các ảnh của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", "user_delete_delay_settings": "Thời gian xóa", "user_delete_delay_settings_description": "Số ngày chờ xóa để xóa vĩnh viễn tài khoản và các ảnh của người dùng. Tác vụ xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", @@ -1111,6 +1117,7 @@ "search_options": "Tùy chọn tìm kiếm", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", + "search_settings": "Cài đặt tìm kiếm", "search_state": "Tìm kiếm tỉnh...", "search_tags": "Tìm kiếm thẻ...", "search_timezone": "Tìm kiếm múi giờ...", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index fb9a18a1f5..9680363293 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", "background_task_job": "背景任務", "check_all": "全選", - "cleared_jobs": "已清除 {job} 的任務", + "cleared_jobs": "已清除的作業:{job}", "config_set_by_file": "目前的設定已透過設定檔案設置", "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", "confirm_delete_library_assets": "您確定要刪除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", "confirm_email_below": "請在底下輸入 {email} 來確認", "confirm_reprocess_all_faces": "確定要重新處理所有臉孔嗎?這會清除已命名的人物。", "confirm_user_password_reset": "您確定要重設 {user} 的密碼嗎?", + "create_job": "建立作業", "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "縮圖解析度", "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "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 {# 項}}任務失敗", + "job_settings": "作業設定", + "job_settings_description": "管理作業並行", + "job_status": "作業狀態", + "jobs_delayed": "已延後 {jobCount, plural, other {# 項作業}}", + "jobs_failed": "{jobCount, plural, other {# 項}}作業失敗", "library_created": "已建立圖庫:{library}", "library_cron_expression": "Cron 運算式", "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", @@ -95,7 +97,7 @@ "logging_level_description": "啟用時的記錄層級。", "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", - "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", + "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", "machine_learning_duplicate_detection": "重複項目偵測", "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", @@ -198,6 +200,7 @@ "password_settings": "密碼登入", "password_settings_description": "管理密碼登入設定", "paths_validated_successfully": "所有路徑驗證成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", "registration": "管理者註冊", @@ -209,8 +212,9 @@ "require_password_change_on_login": "要求使用者在首次登入時更改密碼", "reset_settings_to_default": "將設定重設回預設", "reset_settings_to_recent_saved": "已設回最後儲存的設定", - "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", - "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", + "scanning_library_for_changed_files": "掃描圖庫中變更的檔案", + "scanning_library_for_new_files": "掃描圖庫中的新檔案", + "search_jobs": "搜尋作業…", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", "storage_template_user_label": "{label} 是使用者的儲存標籤", "system_settings": "系統設定", + "tag_cleanup_job": "清理標記", "theme_custom_css_settings": "自訂 CSS", "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", "theme_settings": "主題", @@ -312,8 +317,9 @@ "trash_settings_description": "管理垃圾桶設定", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", - "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", - "user_delete_delay_settings": "刪除延遲", + "user_cleanup_job": "清理使用者", + "user_delete_delay": "{user} 的帳號和檔案將於 {delay, plural, other {# 天}}後永久刪除。", + "user_delete_delay_settings": "延後刪除", "user_delete_delay_settings_description": "移除後永久刪除用戶帳戶和資產的天數。用戶刪除任務會在午夜運行,以檢查是否有準備好刪除的用戶。對此設置的更改將在下一次執行時進行評估。", "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", @@ -593,7 +599,7 @@ "cant_get_number_of_comments": "無法獲取評論數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", - "cleared_jobs": "已清除以下工作的任務: {job}", + "cleared_jobs": "已清除的作業:{job}", "error_adding_assets_to_album": "將檔案加入相簿時出錯", "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", @@ -694,8 +700,8 @@ "unable_to_save_name": "無法儲存名稱", "unable_to_save_profile": "無法儲存個人資料", "unable_to_save_settings": "無法儲存設定", - "unable_to_scan_libraries": "無法掃描資料庫", - "unable_to_scan_library": "無法掃描資料庫", + "unable_to_scan_libraries": "無法掃描圖庫", + "unable_to_scan_library": "無法掃描圖庫", "unable_to_set_feature_photo": "無法設置特色照片", "unable_to_set_profile_picture": "無法設置個人頭像", "unable_to_submit_job": "無法提交作業", @@ -722,7 +728,7 @@ "expired": "已過期", "expires_date": "失效期限:{date}", "explore": "探索", - "explorer": "探測器", + "explorer": "總攬", "export": "匯出", "export_as_json": "匯出 JSON", "extension": "副檔名", @@ -747,7 +753,7 @@ "fix_incorrect_match": "修復不相符的", "folders": "資料夾", "folders_feature_description": "以資料夾瀏覽檔案系統中的照片和影片", - "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", + "force_re-scan_library_files": "強制重新掃描所有圖庫檔案", "forward": "順序", "general": "一般", "get_help": "線上求助", @@ -802,7 +808,7 @@ "invite_to_album": "邀請至相簿", "items_count": "{count, plural, other {# 個項目}}", "job_settings_description": "", - "jobs": "工作", + "jobs": "作業", "keep": "保留", "keep_all": "全部保留", "keyboard_shortcuts": "鍵盤快捷鍵", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index 08c236dcbf..6c4a433b1b 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需再次启用,使用 服务器指令。", "background_task_job": "后台任务", "check_all": "检查全部", - "cleared_jobs": "已清理作业:{job}", + "cleared_jobs": "已清理任务:{job}", "config_set_by_file": "当前配置已通过配置文件设置", "confirm_delete_library": "确定要删除图库“{library}”吗?", "confirm_delete_library_assets": "确定要删除该图库吗?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。但文件仍将保留在磁盘中。", "confirm_email_below": "输入“{email}”来确认", "confirm_reprocess_all_faces": "确定要对全部照片重新进行面部识别吗?这将同时清除所有已命名人物。", "confirm_user_password_reset": "确定要重置用户{user}的密码吗?", + "create_job": "创建任务", "crontab_guru": "Crontab Guru", "disable_login": "禁用登录", "disabled": "已禁用", @@ -51,7 +52,7 @@ "face_detection": "人脸检测", "face_detection_description": "使用机器学习检测项目中的人脸(视频只检测其缩略图中的人脸)。选择“全部”项将会(重新)处理所有项目。选择“缺失”项将尚未处理的项目置于队列中。人脸检测完成后,检测到的人脸将排队进行面部识别,将它们分组到现有的或新的人物中。", "facial_recognition_job_description": "将检测到的人脸按照人物分组。这一步将在人脸检测完成后执行。选择“全部”项将会(重新)分组所有面孔。选择“缺失”项将尚未分配的人脸置于队列中。", - "failed_job_command": "{command}命令执行失败的作业:{job}", + "failed_job_command": "{command}命令执行失败的任务:{job}", "force_delete_user_warning": "警告:这将立即移除用户以及所有项目。该操作无法撤回且文件无法恢复。", "forcing_refresh_library_files": "强制刷新所有图库文件", "image_format_description": "WebP 文件比 JPEG 文件小,但编码速度较慢。", @@ -70,11 +71,12 @@ "image_thumbnail_resolution": "缩略图分辨率", "image_thumbnail_resolution_description": "用于查看照片组(主时间轴、相册视图等)。更高的分辨率可以保留更多的细节,但编码时间更长,文件体积更大,并会降低应用程序的响应速度。", "job_concurrency": "{job}并发", + "job_created": "任务已创建", "job_not_concurrency_safe": "此任务并发并不安全。", "job_settings": "任务设置", "job_settings_description": "管理任务并发", "job_status": "任务状态", - "jobs_delayed": "{jobCount, plural, other {#项作业已推迟}}", + "jobs_delayed": "{jobCount, plural, other {#项任务已推迟}}", "jobs_failed": "{jobCount, plural, other {#项失败}}", "library_created": "已创建图库:{library}", "library_cron_expression": "Cron 表达式", @@ -95,7 +97,7 @@ "logging_level_description": "启用的日志级别。", "logging_settings": "日志", "machine_learning_clip_model": "CLIP模型", - "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”作业。", + "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”任务。", "machine_learning_duplicate_detection": "重复项检测", "machine_learning_duplicate_detection_enabled": "启用重复检测", "machine_learning_duplicate_detection_enabled_description": "如果禁用此功能,完全相同的项目仍将被去重。", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“张三<12345@qq.com>”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich Photo Server ”", "notification_email_host_description": "服务器地址:(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", @@ -198,6 +200,7 @@ "password_settings": "密码登录", "password_settings_description": "管理密码登录设置", "paths_validated_successfully": "所有路径验证成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配额大小(GB)", "refreshing_all_libraries": "刷新所有图库", "registration": "注册管理员", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "恢复到最近保存的设置", "scanning_library_for_changed_files": "扫描图库变更的文件", "scanning_library_for_new_files": "扫描图库新增的文件", + "search_jobs": "搜索任务...", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上传项目文件夹结构和文件名", "storage_template_user_label": "{label}是用户的存储标签", "system_settings": "系统设置", + "tag_cleanup_job": "清理标签", "theme_custom_css_settings": "自定义CSS", "theme_custom_css_settings_description": "可以通过CSS自定义Immich外观。", "theme_settings": "主题设置", @@ -312,6 +317,7 @@ "trash_settings_description": "管理回收站设置", "untracked_files": "未被追踪的文件", "untracked_files_description": "这些文件未被系统追踪。 这可能是移动失败、上传中断或因bug而落下", + "user_cleanup_job": "清理用户", "user_delete_delay": "{user}的账户及项目将在{delay, plural, one {#天} other {#天}}后自动永久删除。", "user_delete_delay_settings": "延期删除", "user_delete_delay_settings_description": "删除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", @@ -594,7 +600,7 @@ "cant_get_number_of_comments": "无法获取评论数量", "cant_search_people": "无法检索人物", "cant_search_places": "无法检索地点", - "cleared_jobs": "已删除作业:{job}", + "cleared_jobs": "已删除任务:{job}", "error_adding_assets_to_album": "添加项目到相册时出错", "error_adding_users_to_album": "添加用户到相册时出错", "error_deleting_shared_user": "删除共享用户时出错", From 1ef283460345b2e9e87106790e10a8af2e32ae61 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:30:01 -0400 Subject: [PATCH 061/599] docs: hidden files cursed knowledge (#12929) --- docs/src/pages/cursed-knowledge.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 55bb3d4cee..1e5c724d16 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -6,6 +6,7 @@ import { mdiLeadPencil, mdiLockOff, mdiLockOutline, + mdiMicrosoftWindows, mdiSecurity, mdiSpeedometerSlow, mdiTrashCan, @@ -21,6 +22,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiMicrosoftWindows, + iconColor: '#357EC7', + title: 'Hidden files in Windows are cursed', + description: + 'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.', + link: { + url: 'https://github.com/immich-app/immich/pull/12812', + text: '#12812', + }, + date: new Date(2024, 8, 20), + }, { icon: mdiWrap, iconColor: 'gray', From b2f2be34855d7c70fc699d4504d72b037df44e0b Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 25 Sep 2024 19:26:19 +0200 Subject: [PATCH 062/599] refactor(server): library syncing (#12220) * refactor: library scanning fix tests remove offline files step cleanup library service improve tests cleanup tests add db migration fix e2e cleanup openapi fix tests fix tests update docs update docs update mobile code fix formatting don't remove assets from library with invalid import path use trash for offline files add migration simplify scan endpoint cleanup library panel fix library tests e2e lint fix e2e trash e2e fix lint add asset trash tests add more tests ensure thumbs are generated cleanup svelte cleanup queue names fix tests fix lint add warning due to trash fix trash tests fix lint fix tests Admin message for offline asset fix comments Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> add permission to library scan endpoint revert asset interface sort add trash reason to shared link stub improve path view in offline update docs improve trash performance fix comments remove stray comment * refactor: add back isOffline and remove trashReason from asset, change sync job flow * chore(server): drop coverage to 80% for functions * chore: rebase and generated files --------- Co-authored-by: Zack Pollard --- docs/docs/features/libraries.md | 46 +- e2e/src/api/specs/library.e2e-spec.ts | 476 +++++---------- e2e/src/api/specs/search.e2e-spec.ts | 2 +- e2e/src/api/specs/trash.e2e-spec.ts | 113 +++- e2e/src/utils.ts | 14 + mobile/lib/entities/asset.entity.g.dart | 141 ++--- .../lib/extensions/collection_extensions.dart | 5 +- .../asset_viewer/bottom_gallery_bar.dart | 29 +- .../asset_viewer/top_control_app_bar.dart | 3 +- mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/assets_api.dart | 14 +- mobile/openapi/lib/api/libraries_api.dart | 54 +- mobile/openapi/lib/api_client.dart | 2 - .../openapi/lib/model/scan_library_dto.dart | 125 ---- open-api/immich-openapi-specs.json | 59 -- open-api/typescript-sdk/src/fetch-client.ts | 19 +- server/src/controllers/library.controller.ts | 28 +- server/src/dtos/asset-media.dto.ts | 3 - server/src/dtos/library.dto.ts | 10 +- server/src/interfaces/asset.interface.ts | 3 - server/src/interfaces/job.interface.ts | 27 +- server/src/queries/asset.repository.sql | 41 -- server/src/repositories/asset.repository.ts | 60 +- server/src/repositories/job.repository.ts | 10 +- server/src/repositories/trash.repository.ts | 11 +- server/src/services/asset-media.service.ts | 1 - server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 561 +++++------------- server/src/services/library.service.ts | 403 ++++++------- server/src/services/microservices.service.ts | 10 +- server/src/services/trash.service.spec.ts | 4 +- server/src/utils/database.ts | 2 +- server/test/fixtures/asset.stub.ts | 214 +++---- .../repositories/asset.repository.mock.ts | 1 - server/vitest.config.mjs | 2 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 3 +- .../asset-viewer/detail-panel.svelte | 15 +- .../buttons/circle-icon-button.svelte | 3 +- web/src/lib/i18n/ar.json | 6 +- web/src/lib/i18n/bg.json | 6 +- web/src/lib/i18n/bi.json | 6 +- web/src/lib/i18n/ca.json | 6 +- web/src/lib/i18n/cs.json | 6 +- web/src/lib/i18n/da.json | 6 +- web/src/lib/i18n/de.json | 6 +- web/src/lib/i18n/en.json | 25 +- web/src/lib/i18n/es.json | 6 +- web/src/lib/i18n/fa.json | 6 +- web/src/lib/i18n/fi.json | 4 +- web/src/lib/i18n/fr.json | 6 +- web/src/lib/i18n/he.json | 6 +- web/src/lib/i18n/hi.json | 6 +- web/src/lib/i18n/hr.json | 4 +- web/src/lib/i18n/hu.json | 6 +- web/src/lib/i18n/hy.json | 6 +- web/src/lib/i18n/id.json | 6 +- web/src/lib/i18n/it.json | 6 +- web/src/lib/i18n/ja.json | 6 +- web/src/lib/i18n/kmr.json | 6 +- web/src/lib/i18n/ko.json | 6 +- web/src/lib/i18n/lt.json | 2 +- web/src/lib/i18n/lv.json | 2 +- web/src/lib/i18n/mn.json | 2 +- web/src/lib/i18n/nb_NO.json | 6 +- web/src/lib/i18n/nl.json | 6 +- web/src/lib/i18n/pl.json | 6 +- web/src/lib/i18n/pt.json | 6 +- web/src/lib/i18n/pt_BR.json | 6 +- web/src/lib/i18n/ro.json | 4 +- web/src/lib/i18n/ru.json | 6 +- web/src/lib/i18n/sk.json | 2 +- web/src/lib/i18n/sl.json | 2 +- web/src/lib/i18n/sr_Cyrl.json | 6 +- web/src/lib/i18n/sr_Latn.json | 6 +- web/src/lib/i18n/sv.json | 4 +- web/src/lib/i18n/ta.json | 6 +- web/src/lib/i18n/th.json | 4 +- web/src/lib/i18n/tr.json | 6 +- web/src/lib/i18n/uk.json | 6 +- web/src/lib/i18n/vi.json | 6 +- web/src/lib/i18n/zh_Hant.json | 6 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 6 +- web/src/lib/utils/asset-utils.ts | 7 - .../admin/library-management/+page.svelte | 88 +-- 85 files changed, 941 insertions(+), 1926 deletions(-) delete mode 100644 mobile/openapi/lib/model/scan_library_dto.dart diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index cdea1a11a5..1755546954 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -1,18 +1,14 @@ -# Libraries +# External Libraries -## Overview +External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up. -Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. +If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost. -## External Libraries +:::caution -External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. +If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. -If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: - -- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets -- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. -- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. +::: :::caution @@ -20,22 +16,6 @@ Due to aggressive caching it can take some time for a refreshed asset to appear ::: -In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. - -:::caution - -If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. - -::: - -### Deleted External Assets - -Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work. - -In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. - -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. - ### Import Paths External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible. @@ -66,9 +46,13 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +Special characters such as @ should be escaped, for instance: + +- `**/\@eadir/**` will exclude all files in any directory named `@eadir` + ### Automatic watching (EXPERIMENTAL) -This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. +This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. @@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. ## Usage @@ -120,7 +104,7 @@ This will disallow the images from being deleted in the web UI, or adding metada _Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Create External Libraries +### Create A New Library These actions must be performed by the Immich administrator. @@ -144,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files. - Enter `**/Raw/**` and click save. - Click save - Click the drop-down menu on the newly created library -- Click on Scan Library Files +- Click on Scan The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. @@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something - Click on Add Path - Enter `/mnt/media/videos` then click Add - Click Save -- Click on Scan Library Files +- Click on Scan Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 8d98e86630..20bd230159 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,11 +1,4 @@ -import { - LibraryResponseDto, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - removeOfflineFiles, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -15,8 +8,7 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => - scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); describe('/libraries', () => { let admin: LoginResponseDto; @@ -293,14 +285,19 @@ describe('/libraries', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should scan external library', async () => { + it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -315,8 +312,13 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -330,8 +332,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -340,95 +347,144 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); - it('should pick up new files', async () => { + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(1); }); - it('should offline a file missing from disk', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(0); + }); + + it('should set an asset offline if its file is missing', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(3); + expect(assets.count).toBe(1); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toEqual(true); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(newAssets.count).toBe(3); - - expect(newAssets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetC.png', - }), - ]), - ); + expect(newAssets.items).toEqual([]); }); - it('should offline a file outside of import paths', async () => { + it('should set an asset offline its file is not in any import path', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + + utils.createDirectory(`${testAssetDir}/temp/another-path/`); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); + .send({ importPaths: [`${testAssetDirInternal}/temp/another-path/`] }); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([]); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + utils.removeDirectory(`${testAssetDir}/temp/another-path/`); }); - it('should offline a file covered by an exclusion pattern', async () => { + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -437,6 +493,12 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + originalFileName: 'assetB.png', + }); + expect(assets.count).toBe(1); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) @@ -445,282 +507,21 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.isTrashed).toBe(true); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.count).toBe(2); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'assetA.png', + }), + ]); }); - it('should not try to delete offline files', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline1`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(initialAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); - - expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - }); - - it('should scan new files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(3); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - originalFileName: 'assetC.png', - }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - }); - - describe('with refreshModifiedFiles=true', () => { - it('should reimport modified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - - it('should not reimport unmodified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(0); - }); - }); - - describe('with refreshAllFiles=true', () => { - it('should reimport all files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshAllFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - }); - }); - - describe('POST /libraries/:id/removeOffline', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should remove offline files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - expect(initialAssets.count).toBe(2); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should remove offline files from trash', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - - expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - expect(assets.items[0].isOffline).toBe(false); - expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should not remove online files', async () => { + it('should not trash an online asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -733,10 +534,11 @@ describe('/libraries', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -828,7 +630,7 @@ describe('/libraries', () => { }); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index beeaf1cc01..0e5d882f80 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -181,7 +181,7 @@ describe('/search', () => { dto: { size: -1.5 }, expected: ['size must not be less than 1', 'size must be an integer number'], }, - ...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({ + ...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({ should: `should reject ${value} not a boolean`, dto: { [value]: 'immich' }, expected: [`${value} must be a boolean value`], diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 17bb568c61..0bfc0ec19b 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,10 +1,13 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); + describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -44,6 +47,8 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); }); it('should empty the trash with archived assets', async () => { @@ -64,6 +69,46 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should not delete offline-trashed assets from disk', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.items.length).toBe(1); + const asset = assets.items[0]; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true }); + + expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -91,6 +136,37 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); }); + + it('should not restore offline-trashed assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + }); }); describe('POST /trash/restore/assets', () => { @@ -118,5 +194,38 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); + + it('should not restore an offline-trashed asset', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const after = await utils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3c9d4284ce..e21b3bfd14 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -372,6 +372,12 @@ export const utils = { writeFileSync(path, makeRandomImage()); }, + createDirectory: (path: string) => { + if (!existsSync(dirname(path))) { + mkdirSync(dirname(path), { recursive: true }); + } + }, + removeImageFile: (path: string) => { if (!existsSync(path)) { return; @@ -380,6 +386,14 @@ export const utils = { rmSync(path); }, + removeDirectory: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 23bf236046..8be636efb6 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isOffline': PropertySchema( - id: 8, - name: r'isOffline', - type: IsarType.bool, - ), r'isTrashed': PropertySchema( - id: 9, + id: 8, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 10, + id: 9, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 11, + id: 10, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 12, + id: 11, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 13, + id: 12, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 14, + id: 13, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 15, + id: 14, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 16, + id: 15, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 17, + id: 16, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 18, + id: 17, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 19, + id: 18, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 20, + id: 19, name: r'width', type: IsarType.int, ) @@ -244,19 +239,18 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isOffline); - writer.writeBool(offsets[9], object.isTrashed); - writer.writeString(offsets[10], object.livePhotoVideoId); - writer.writeString(offsets[11], object.localId); - writer.writeLong(offsets[12], object.ownerId); - writer.writeString(offsets[13], object.remoteId); - writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackId); - writer.writeString(offsets[16], object.stackPrimaryAssetId); - writer.writeString(offsets[17], object.thumbhash); - writer.writeByte(offsets[18], object.type.index); - writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeBool(offsets[8], object.isTrashed); + writer.writeString(offsets[9], object.livePhotoVideoId); + writer.writeString(offsets[10], object.localId); + writer.writeLong(offsets[11], object.ownerId); + writer.writeString(offsets[12], object.remoteId); + writer.writeLong(offsets[13], object.stackCount); + writer.writeString(offsets[14], object.stackId); + writer.writeString(offsets[15], object.stackPrimaryAssetId); + writer.writeString(offsets[16], object.thumbhash); + writer.writeByte(offsets[17], object.type.index); + writer.writeDateTime(offsets[18], object.updatedAt); + writer.writeInt(offsets[19], object.width); } Asset _assetDeserialize( @@ -275,20 +269,19 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isOffline: reader.readBoolOrNull(offsets[8]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[10]), - localId: reader.readStringOrNull(offsets[11]), - ownerId: reader.readLong(offsets[12]), - remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]) ?? 0, - stackId: reader.readStringOrNull(offsets[15]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), - thumbhash: reader.readStringOrNull(offsets[17]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? + isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[9]), + localId: reader.readStringOrNull(offsets[10]), + ownerId: reader.readLong(offsets[11]), + remoteId: reader.readStringOrNull(offsets[12]), + stackCount: reader.readLongOrNull(offsets[13]) ?? 0, + stackId: reader.readStringOrNull(offsets[14]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), + thumbhash: reader.readStringOrNull(offsets[16]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + updatedAt: reader.readDateTime(offsets[18]), + width: reader.readIntOrNull(offsets[19]), ); return object; } @@ -319,29 +312,27 @@ P _assetDeserializeProp

    ( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readBoolOrNull(offset) ?? false) as P; + return (reader.readStringOrNull(offset)) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readStringOrNull(offset)) as P; - case 12: return (reader.readLong(offset)) as P; - case 13: + case 12: return (reader.readStringOrNull(offset)) as P; - case 14: + case 13: return (reader.readLongOrNull(offset) ?? 0) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: - return (reader.readStringOrNull(offset)) as P; - case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 19: + case 18: return (reader.readDateTime(offset)) as P; - case 20: + case 19: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1362,16 +1353,6 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder isOfflineEqualTo( - bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'isOffline', - value: value, - )); - }); - } - QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2647,18 +2628,6 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder sortByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2913,18 +2882,6 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder thenByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3121,12 +3078,6 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'isOffline'); - }); - } - QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3263,12 +3214,6 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder isOfflineProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'isOffline'); - }); - } - QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 769bec472b..f71b0aacd3 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -72,13 +72,14 @@ extension AssetListExtension on Iterable { } /// Filters out offline assets and returns those that are still accessible by the Immich server + /// TODO: isOffline is removed from Immich, so this method is not useful anymore Iterable nonOfflineOnly({ void Function()? errorCallback, }) { - final bool onlyLive = every((e) => !e.isOffline); + final bool onlyLive = every((e) => false); if (!onlyLive) { if (errorCallback != null) errorCallback(); - return where((a) => !a.isOffline); + return where((a) => false); } return this; } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 7e6136c256..8b5684d0fa 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -172,29 +172,12 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { final image = Image(image: ImmichImage.imageProvider(asset: asset)); - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_edit_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } + Navigator.of(context).push( MaterialPageRoute( builder: (context) => EditImagePage( @@ -219,16 +202,6 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - ref.read(imageViewerStateProvider.notifier).downloadAsset( asset, context, diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 2157a1aebb..984b61f50c 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) - buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b6b0897e8f..e337c4831f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -133,7 +133,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | *LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | @@ -385,7 +384,6 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08b6fc521..22b48df2fb 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -197,7 +197,6 @@ part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; -part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index bd1d5b8484..fd89986980 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -833,14 +833,12 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/assets'; @@ -896,10 +894,6 @@ class AssetsApi { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isOffline != null) { - hasFields = true; - mp.fields[r'isOffline'] = parameterToString(isOffline); - } if (isVisible != null) { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); @@ -951,15 +945,13 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 53ab0e19ce..36d98d9a88 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -243,13 +243,13 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - Future removeOfflineFilesWithHttpInfo(String id,) async { + Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/removeOffline' + final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -276,52 +276,8 @@ class LibrariesApi { /// Parameters: /// /// * [String] id (required): - Future removeOfflineFiles(String id,) async { - final response = await removeOfflineFilesWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/scan' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody = scanLibraryDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { - final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + Future scanLibrary(String id,) async { + final response = await scanLibraryWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c62d1c5b2e..3db3297acb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -448,8 +448,6 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); - case 'ScanLibraryDto': - return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart deleted file mode 100644 index 8ff978be05..0000000000 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ /dev/null @@ -1,125 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class ScanLibraryDto { - /// Returns a new [ScanLibraryDto] instance. - ScanLibraryDto({ - this.refreshAllFiles, - this.refreshModifiedFiles, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshAllFiles; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshModifiedFiles; - - @override - bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && - other.refreshAllFiles == refreshAllFiles && - other.refreshModifiedFiles == refreshModifiedFiles; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + - (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); - - @override - String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; - - Map toJson() { - final json = {}; - if (this.refreshAllFiles != null) { - json[r'refreshAllFiles'] = this.refreshAllFiles; - } else { - // json[r'refreshAllFiles'] = null; - } - if (this.refreshModifiedFiles != null) { - json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; - } else { - // json[r'refreshModifiedFiles'] = null; - } - return json; - } - - /// Returns a new [ScanLibraryDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static ScanLibraryDto? fromJson(dynamic value) { - upgradeDto(value, "ScanLibraryDto"); - if (value is Map) { - final json = value.cast(); - - return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), - refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = ScanLibraryDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = ScanLibraryDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of ScanLibraryDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1a070f126b..d0864675a1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2853,41 +2853,6 @@ ] } }, - "/libraries/{id}/removeOffline": { - "post": { - "operationId": "removeOfflineFiles", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Libraries" - ] - } - }, "/libraries/{id}/scan": { "post": { "operationId": "scanLibrary", @@ -2902,16 +2867,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanLibraryDto" - } - } - }, - "required": true - }, "responses": { "204": { "description": "" @@ -8287,9 +8242,6 @@ "isFavorite": { "type": "boolean" }, - "isOffline": { - "type": "boolean" - }, "isVisible": { "type": "boolean" }, @@ -10628,17 +10580,6 @@ ], "type": "object" }, - "ScanLibraryDto": { - "properties": { - "refreshAllFiles": { - "type": "boolean" - }, - "refreshModifiedFiles": { - "type": "boolean" - } - }, - "type": "object" - }, "SearchAlbumResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f2f946f262..85710af49c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -366,7 +366,6 @@ export type AssetMediaCreateDto = { fileModifiedAt: string; isArchived?: boolean; isFavorite?: boolean; - isOffline?: boolean; isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; @@ -579,10 +578,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type ScanLibraryDto = { - refreshAllFiles?: boolean; - refreshModifiedFiles?: boolean; -}; export type LibraryStatsResponseDto = { photos: number; total: number; @@ -2066,24 +2061,14 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } -export function removeOfflineFiles({ id }: { +export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, { ...opts, method: "POST" })); } -export function scanLibrary({ id, scanLibraryDto }: { - id: string; - scanLibraryDto: ScanLibraryDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ - ...opts, - method: "POST", - body: scanLibraryDto - }))); -} export function getLibraryStatistics({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index a45617fc2a..b8959ca288 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -4,7 +4,6 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, @@ -43,6 +42,13 @@ export class LibraryController { return this.service.update(id, dto); } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); + } + @Post(':id/validate') @HttpCode(200) @Authenticated({ admin: true }) @@ -51,13 +57,6 @@ export class LibraryController { return this.service.validate(id, dto); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) - deleteLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.delete(id); - } - @Get(':id/statistics') @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { @@ -66,15 +65,8 @@ export class LibraryController { @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(id, dto); - } - - @Post(':id/removeOffline') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - removeOfflineFiles(@Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(id); + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + scanLibrary(@Param() { id }: UUIDParamDto) { + return this.service.queueScan(id); } } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index e9e346c4cb..c62857da65 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isVisible?: boolean; - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index c2c3ac9d27..7fb363dd9a 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { LibraryEntity } from 'src/entities/library.entity'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @ValidateUUID() @@ -89,14 +89,6 @@ export class LibrarySearchDto { userId?: string; } -export class ScanLibraryDto { - @ValidateBoolean({ optional: true }) - refreshModifiedFiles?: boolean; - - @ValidateBoolean({ optional: true }) - refreshAllFiles?: boolean; -} - export class LibraryResponseDto { id!: string; ownerId!: string; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 387fa27185..c6808e3aa8 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -36,8 +36,6 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', - IS_ONLINE = 'isOnline', - IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -176,7 +174,6 @@ export interface IAssetRepository { ): Paginated; getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 3e7b0b9d08..8b6e2c289b 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -76,12 +76,12 @@ export enum JobName { FACIAL_RECOGNITION = 'facial-recognition', // library management - LIBRARY_SCAN = 'library-refresh', - LIBRARY_SCAN_ASSET = 'library-refresh-asset', - LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', - LIBRARY_CHECK_OFFLINE = 'library-check-offline', + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILE = 'library-sync-file', + LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup @@ -137,16 +137,11 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } -export interface ILibraryOfflineJob extends IEntityJob { +export interface ILibraryAssetJob extends IEntityJob { importPaths: string[]; exclusionPatterns: string[]; } -export interface ILibraryRefreshJob extends IEntityJob { - refreshModifiedFiles: boolean; - refreshAllFiles: boolean; -} - export interface IBulkEntityJob extends IBaseJob { ids: string[]; } @@ -277,12 +272,12 @@ export type JobItem = | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } // Library Management - | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } - | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } - | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 5b57307179..6930932584 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -268,35 +268,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getExternalLibraryAssetPaths -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) - WHERE - ( - ( - ((("AssetEntity__AssetEntity_library"."id" = $1))) - AND ("AssetEntity"."isExternal" = $2) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC -LIMIT - 2 - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -366,18 +337,6 @@ WHERE AND "originalPath" = path ); --- AssetRepository.updateOfflineLibraryAssets -UPDATE "assets" -SET - "isOffline" = $1, - "updatedAt" = CURRENT_TIMESTAMP -WHERE - ( - "libraryId" = $2 - AND NOT ("originalPath" IN ($3)) - AND "isOffline" = $4 - ) - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 4ec5523df1..43e765d00b 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -13,7 +13,6 @@ import { AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, - AssetPathEntity, AssetStats, AssetStatsOptions, AssetUpdateAllOptions, @@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { - select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId }, isExternal: true }, - }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { return this.repository.findOne({ @@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository { async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { const result = await this.repository.query( ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path + FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, [libraryId, originalPaths], ); return result.map((row: { path: string }) => row.path); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); @@ -373,12 +356,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql( - ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) - .map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), + ...Object.values(WithProperty).map((property) => ({ + name: property, + params: [DummyValue.PAGINATION, property], + })), ) getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { let relations: FindOptionsRelations = {}; @@ -531,26 +512,16 @@ export class AssetRepository implements IAssetRepository { where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; break; } - case WithProperty.IS_OFFLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding offline assets'); - } - where = [{ isOffline: true, libraryId }]; - break; - } - case WithProperty.IS_ONLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding online assets'); - } - where = [{ isOffline: false, libraryId }]; - break; - } default: { throw new Error(`Invalid getWith property: ${property}`); } } + if (libraryId) { + where = [{ ...where, libraryId }]; + } + return paginate(this.repository, pagination, { where, withDeleted, @@ -750,7 +721,10 @@ export class AssetRepository implements IAssetRepository { builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); if (options.isTrashed) { - builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); + // TODO: Temporarily inverted to support showing offline assets in the trash queries. + // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED + // and the offline screens should use a separate isOffline = true parameter in the timeline query. + builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 2b4c1f6dc1..cd4c7135be 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -79,12 +79,12 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management - [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, - [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, - [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, // Notification diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts index 9e0f6728f1..d24f4f709a 100644 --- a/server/src/repositories/trash.repository.ts +++ b/server/src/repositories/trash.repository.ts @@ -3,7 +3,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus } from 'src/enum'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; export class TrashRepository implements ITrashRepository { constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} @@ -26,7 +26,7 @@ export class TrashRepository implements ITrashRepository { async restore(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()) }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.ACTIVE, deletedAt: null }, ); @@ -35,7 +35,7 @@ export class TrashRepository implements ITrashRepository { async empty(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.DELETED }, ); @@ -43,7 +43,10 @@ export class TrashRepository implements ITrashRepository { } async restoreAll(ids: string[]): Promise { - const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null }); + const result = await this.assetRepository.update( + { id: In(ids), status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); return result.affected ?? 0; } } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 5321c335a7..d3dce323f0 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -427,7 +427,6 @@ export class AssetMediaService { livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, - isOffline: dto.isOffline ?? false, }); if (sidecarFile) { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 5ed9f32024..f978f33410 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -164,7 +164,7 @@ export class JobService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); } default: { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 36bdfd05dc..8b14c76cbc 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, @@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; +async function* mockWalk() { + yield await Promise.resolve(['/data/user1/photo.jpg']); +} + describe(LibraryService.name, () => { let sut: LibraryService; @@ -91,7 +94,7 @@ describe(LibraryService.name, () => { enabled: true, cronExpression: '0 1 * * *', }, - watch: { enabled: false }, + watch: { enabled: true }, }, } as SystemConfig); @@ -163,102 +166,29 @@ describe(LibraryService.name, () => { describe('handleQueueAssetRefresh', () => { it('should queue refresh of a new asset', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); + storageMock.walk.mockImplementation(mockWalk); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibrary1.id, ownerId: libraryStub.externalLibrary1.owner.id, assetPath: '/data/user1/photo.jpg', - force: false, - }, - }, - ]); - }); - - it('should queue offline check of existing online assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { - id: assetStub.external.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: [], }, }, ]); }); it("should fail when library can't be found", async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - }); - - it('should force queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - force: true, - }, - }, - ]); + await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { @@ -276,16 +206,9 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibraryWithImportPaths1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], @@ -296,9 +219,36 @@ describe(LibraryService.name, () => { }); }); - describe('handleOfflineCheck', () => { + describe('handleQueueRemoveDeleted', () => { + it('should queue online check of existing assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + + await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); + + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + }); + }); + + describe('handleSyncAsset', () => { it('should skip missing assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], @@ -306,41 +256,31 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(null); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.update).not.toHaveBeenCalled(); - }); - - it('should do nothing with already-offline assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.offline); - - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should offline assets matching an exclusion pattern', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: ['**/user1/**'], @@ -348,13 +288,15 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should set assets outside of import paths as offline', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/data/user2'], exclusionPatterns: [], @@ -363,28 +305,74 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should do nothing with online assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should un-trash an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.trashedOffline); + storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + deletedAt: null, + fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, + fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, + isOffline: false, + originalFileName: 'path.jpg', + }); }); }); - describe('handleAssetRefresh', () => { + it('should update file when mtime has changed', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + const newMTime = new Date(); + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + fileModifiedAt: newMTime, + fileCreatedAt: newMTime, + isOffline: false, + originalFileName: 'photo.jpg', + deletedAt: null, + }); + }); + + describe('handleSyncFile', () => { let mockUser: UserEntity; beforeEach(() => { @@ -397,42 +385,18 @@ describe(LibraryService.name, () => { } as Stats); }); - it('should reject an unknown file extension', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should reject an unknown file type', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should add a new image', async () => { + it('should import a new asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -467,19 +431,19 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new image with sidecar', async () => { + it('should import a new asset with sidecar', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -514,18 +478,18 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new video', async () => { + it('should import a new video', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/video.mp4', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -568,29 +532,27 @@ describe(LibraryService.name, () => { ]); }); - it('should not add an image to a soft deleted library', async () => { + it('should not import an asset to a soft deleted library', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); - it('should not import an asset when mtime matches db asset', async () => { + it('should not refresh a file whose mtime matches existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: assetStub.hasFileExtension.originalPath, - force: false, }; storageMock.stat.mockResolvedValue({ @@ -601,190 +563,52 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should import an asset when mtime differs from db asset', async () => { + it('should skip existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.image.id, - }, - }); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); - it('should import an asset that is missing a file extension', async () => { - // This tests for the case where the file extension is missing from the asset path. - // This happened in previous versions of Immich + it('should not refresh an asset trashed by user', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: assetStub.missingFileExtension.originalPath, - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith( - [assetStub.missingFileExtension.id], - expect.objectContaining({ originalFileName: 'photo.jpg' }), - ); - }); - - it('should set a missing asset to offline', async () => { - storageMock.stat.mockRejectedValue(new Error('Path not found')); - - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should online a previously-offline asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.offline.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); - assetMock.create.mockResolvedValue(assetStub.offline); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.offline.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.offline.id, - }, - }); - }); - - it('should do nothing when mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - expect(assetMock.update).not.toHaveBeenCalled(); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - }); - - it('should refresh an existing asset if forced', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.hasFileExtension.ownerId, assetPath: assetStub.hasFileExtension.originalPath, - force: true, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - assetMock.create.mockResolvedValue(assetStub.hasFileExtension); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { - fileCreatedAt: new Date('2023-01-01'), - fileModifiedAt: new Date('2023-01-01'), - originalFileName: assetStub.hasFileExtension.originalFileName, - }); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should refresh an existing asset with modified mtime', async () => { - const filemtime = new Date(); - filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); - - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: filemtime, - ctime: new Date('2023-01-01'), - } as Stats); - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create).toHaveBeenCalled(); - const createdAsset = assetMock.create.mock.calls[0][0]; - - expect(createdAsset.fileModifiedAt).toEqual(filemtime); - }); - - it('should throw error when asset does not exist', async () => { + it('should throw BadRequestException when asset does not exist', async () => { storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: userStub.admin.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); }); }); @@ -857,7 +681,6 @@ describe(LibraryService.name, () => { describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, @@ -1092,12 +915,11 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); @@ -1114,30 +936,16 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); }); - it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), - ); - - await sut.watchAll(); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); - }); - it('should handle an error event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); @@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('should queue a library scan of external library', async () => { + it('should queue a library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, }, }, ], - ]); - }); - - it('should queue a library scan of all modified assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ], - ]); - }); - - it('should queue a forced library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }, - }, - ], - ]); - }); - }); - - describe('queueEmptyTrash', () => { - it('should queue the trash job', async () => { - await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_REMOVE_OFFLINE, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: libraryStub.externalLibrary1.id, }, @@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => { ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ]); - }); - - it('should queue the force refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, }, }, ]); }); }); - describe('handleRemoveOfflineFiles', () => { - it('should queue trash deletion jobs', async () => { - assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + describe('handleQueueAssetOfflineCheck', () => { + it('should queue removal jobs', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.image1.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, + }, + }, ]); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 3dd81dd613..52b786089c 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; -import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; @@ -10,27 +9,26 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, + mapLibrary, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, - mapLibrary, } from 'src/dtos/library.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { - IBaseJob, IEntityJob, IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, - JOBS_LIBRARY_PAGINATION_SIZE, JobName, + JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; @@ -78,11 +76,7 @@ export class LibraryService { this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, - () => - handlePromiseError( - this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), - this.logger, - ), + () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), scan.enabled, ); @@ -143,7 +137,7 @@ export class LibraryService { const handler = async () => { this.logger.debug(`File add event received for ${path} in library ${library.id}}`); if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -151,9 +145,13 @@ export class LibraryService { onChange: (path) => { const handler = async () => { this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset) { + await this.syncAssets(library, [asset.id]); + } if (matcher(path)) { // Note: if the changed file was not previously imported, it will be imported now. - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -162,8 +160,8 @@ export class LibraryService { const handler = async () => { this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset && matcher(path)) { - await this.assetRepository.update({ id: asset.id, isOffline: true }); + if (asset) { + await this.syncAssets(library, [asset.id]); } }; return handlePromiseError(handler(), this.logger); @@ -216,7 +214,7 @@ export class LibraryService { async getStatistics(id: string): Promise { const statistics = await this.repository.getStatistics(id); if (!statistics) { - throw new BadRequestException('Library not found'); + throw new BadRequestException(`Library ${id} not found`); } return statistics; } @@ -250,20 +248,28 @@ export class LibraryService { return mapLibrary(library); } - private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { + private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { await this.jobRepository.queueAll( assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { - id: libraryId, + id, assetPath, ownerId, - force, }, })), ); } + private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) { + await this.jobRepository.queueAll( + assetIds.map((assetId) => ({ + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: assetId, importPaths, exclusionPatterns }, + })), + ); + } + private async validateImportPath(importPath: string): Promise { const validation = new ValidateLibraryImportPathResponseDto(); validation.importPath = importPath; @@ -366,258 +372,182 @@ export class LibraryService { return JobStatus.SUCCESS; } - async handleAssetRefresh(job: ILibraryFileJob): Promise { + async handleSyncFile(job: ILibraryFileJob): Promise { + // Only needs to handle new assets const assetPath = path.normalize(job.assetPath); - const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); - - let stats: Stats; - try { - stats = await this.storageRepository.stat(assetPath); - } catch (error: Error | any) { - // Can't access file, probably offline - if (existingAssetEntity) { - // Mark asset as offline - this.logger.debug(`Marking asset as offline: ${assetPath}`); - - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); - return JobStatus.SUCCESS; - } else { - // File can't be accessed and does not already exist in db - throw new BadRequestException('Cannot access file', { cause: error }); - } - } - - let doImport = false; - let doRefresh = false; - - if (job.force) { - doRefresh = true; - } - - const originalFileName = parse(assetPath).base; - - if (!existingAssetEntity) { - // This asset is new to us, read it from disk - this.logger.debug(`Importing new asset: ${assetPath}`); - doImport = true; - } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { - // File modification time has changed since last time we checked, re-read from disk - this.logger.debug( - `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, - ); - doRefresh = true; - } else if (existingAssetEntity.originalFileName !== originalFileName) { - // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users - this.logger.debug( - `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, - ); - doRefresh = true; - } else if (!job.force && stats && !existingAssetEntity.isOffline) { - // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing - this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); - } - - if (stats && existingAssetEntity?.isOffline) { - // File was previously offline but is now online - this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); - doRefresh = true; - } - - if (!doImport && !doRefresh) { - // If we don't import, exit here + let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + if (asset) { return JobStatus.SKIPPED; } - let assetType: AssetType; - - if (mimeTypes.isImage(assetPath)) { - assetType = AssetType.IMAGE; - } else if (mimeTypes.isVideo(assetPath)) { - assetType = AssetType.VIDEO; - } else { - throw new BadRequestException(`Unsupported file type ${assetPath}`); + let stat; + try { + stat = await this.storageRepository.stat(assetPath); + } catch (error: any) { + if (error.code === 'ENOENT') { + this.logger.error(`File not found: ${assetPath}`); + return JobStatus.SKIPPED; + } + this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`); + return JobStatus.FAILED; } + this.logger.log(`Importing new library asset: ${assetPath}`); + + const library = await this.repository.get(job.id, true); + if (!library || library.deletedAt) { + this.logger.error('Cannot import asset into deleted library'); + return JobStatus.FAILED; + } + + // TODO: device asset id is deprecated, remove it + const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + // TODO: doesn't xmp replace the file extension? Will need investigation let sidecarPath: string | null = null; if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { sidecarPath = `${assetPath}.xmp`; } - // TODO: device asset id is deprecated, remove it - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - let assetId; - if (doImport) { - const library = await this.repository.get(job.id, true); - if (library?.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } + const mtime = stat.mtime; - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + asset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: mtime, + fileModifiedAt: mtime, + localDateTime: mtime, + type: assetType, + originalFileName: parse(assetPath).base, - // TODO: In wait of refactoring the domain asset service, this function is just manually written like this - const addedAsset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, - originalPath: assetPath, - deviceAssetId, - deviceId: 'Library Import', - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - localDateTime: stats.mtime, - type: assetType, - originalFileName, - sidecarPath, - isExternal: true, - }); - assetId = addedAsset.id; - } else if (doRefresh && existingAssetEntity) { - assetId = existingAssetEntity.id; - await this.assetRepository.updateAll([existingAssetEntity.id], { - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - originalFileName, - }); - } else { - // Not importing and not refreshing, do nothing - return JobStatus.SKIPPED; - } + sidecarPath, + isExternal: true, + }); - this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); - - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); - - if (assetType === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); - } + await this.queuePostSyncJobs(asset); return JobStatus.SUCCESS; } - async queueScan(id: string, dto: ScanLibraryDto) { + async queuePostSyncJobs(asset: AssetEntity) { + this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + + if (asset.type === AssetType.VIDEO) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); + } + } + + async queueScan(id: string) { await this.findOrFail(id); await this.jobRepository.queue({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id, - refreshModifiedFiles: dto.refreshModifiedFiles ?? false, - refreshAllFiles: dto.refreshAllFiles ?? false, }, }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - async queueRemoveOffline(id: string) { - this.logger.verbose(`Queueing offline file removal from library ${id}`); - await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); - } - - async handleQueueAllScan(job: IBaseJob): Promise { - this.logger.debug(`Refreshing all external libraries: force=${job.force}`); + async handleQueueSyncAll(): Promise { + this.logger.debug(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { + id: library.id, + }, + })), + ); + await this.jobRepository.queueAll( + libraries.map((library) => ({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: library.id, - refreshModifiedFiles: !job.force, - refreshAllFiles: job.force ?? false, }, })), ); return JobStatus.SUCCESS; } - async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + async handleSyncAsset(job: ILibraryAssetJob): Promise { const asset = await this.assetRepository.getById(job.id); - if (!asset) { - // Asset is no longer in the database, skip return JobStatus.SKIPPED; } - if (asset.isOffline) { - this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); - return JobStatus.SUCCESS; - } + const markOffline = async (explanation: string) => { + if (!asset.isOffline) { + this.logger.debug(`${explanation}, removing: ${asset.originalPath}`); + await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() }); + } + }; const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); if (!isInPath) { - this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is no longer in an import path'); return JobStatus.SUCCESS; } const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); if (isExcluded) { - this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is covered by an exclusion pattern'); return JobStatus.SUCCESS; } - const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); - if (!fileExists) { - this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + let stat; + try { + stat = await this.storageRepository.stat(asset.originalPath); + } catch { + await markOffline('Asset is no longer on disk or is inaccessible because of permissions'); return JobStatus.SUCCESS; } - this.logger.verbose( - `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, - ); + const mtime = stat.mtime; + const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); + if (asset.isOffline || isAssetModified) { + this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); + //TODO: When we have asset status, we need to leave deletedAt as is when status is trashed + await this.assetRepository.updateAll([asset.id], { + isOffline: false, + deletedAt: null, + fileCreatedAt: mtime, + fileModifiedAt: mtime, + originalFileName: parse(asset.originalPath).base, + }); + } + + if (isAssetModified) { + this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); + await this.queuePostSyncJobs(asset); + } return JobStatus.SUCCESS; } - async handleRemoveOffline(job: IEntityJob): Promise { - this.logger.debug(`Removing offline assets for library ${job.id}`); - - const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), - ); - - let offlineAssets = 0; - for await (const assets of assetPagination) { - offlineAssets += assets.length; - if (assets.length > 0) { - this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); - this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); - } - } - - if (offlineAssets) { - this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); - } else { - this.logger.debug(`Found no offline assets to delete from library ${job.id}`); - } - - return JobStatus.SUCCESS; - } - - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { + async handleQueueSyncFiles(job: IEntityJob): Promise { const library = await this.repository.get(job.id); if (!library) { + this.logger.debug(`Library ${job.id} not found, skipping refresh`); return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library ${library.id}`); + this.logger.log(`Refreshing library ${library.id} for new assets`); const validImportPaths: string[] = []; @@ -630,55 +560,66 @@ export class LibraryService { } } - if (validImportPaths.length === 0) { + if (validImportPaths) { + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let count = 0; + + for await (const assetBatch of assetsOnDisk) { + count += assetBatch.length; + this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); + await this.syncFiles(library, assetBatch); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (count > 0) { + this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + } else { this.logger.warn(`No valid import paths found for library ${library.id}`); } - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + await this.repository.update({ id: job.id, refreshedAt: new Date() }); - let crawledAssets = 0; + return JobStatus.SUCCESS; + } - for await (const assetBatch of assetsOnDisk) { - crawledAssets += assetBatch.length; - this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); - await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + async handleQueueSyncAssets(job: IEntityJob): Promise { + const library = await this.repository.get(job.id); + if (!library) { + return JobStatus.SKIPPED; } - if (crawledAssets) { - this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); - } else { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } + this.logger.log(`Scanning library ${library.id} for removed assets`); const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), + this.assetRepository.getAll(pagination, { libraryId: job.id }), ); - let onlineAssetCount = 0; + let assetCount = 0; for await (const assets of onlineAssets) { - onlineAssetCount += assets.length; - this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + assetCount += assets.length; + this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`); await this.jobRepository.queueAll( assets.map((asset) => ({ - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns }, })), ); - this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); + this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`); } - if (onlineAssetCount) { - this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); + if (assetCount) { + this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); } - await this.repository.update({ id: job.id, refreshedAt: new Date() }); - return JobStatus.SUCCESS; } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 25bfc0fdd2..80f1b2be41 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -86,12 +86,12 @@ export class MicroservicesService { [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), - [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), - [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(), + [JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk + [JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets + [JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), - [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 87821f028a..d0c719ae48 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -67,7 +67,7 @@ describe(TrashService.name, () => { }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.restore).toHaveBeenCalledWith('user-id'); @@ -83,7 +83,7 @@ describe(TrashService.name, () => { }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.empty).toHaveBeenCalledWith('user-id'); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f3232eb78b..5f4577f4df 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -80,7 +80,7 @@ export function searchAssetBuilder( }); } - const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']); + const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); const { isArchived, isEncoded, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index a9b5167909..119c0b6e5a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -70,9 +70,9 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, - isOffline: false, isExternal: false, duplicateId: null, + isOffline: false, }), noWebpPath: Object.freeze({ @@ -104,13 +104,13 @@ export const assetStub = { originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, - isOffline: false, isExternal: false, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), noThumbhash: Object.freeze({ @@ -133,7 +133,6 @@ export const assetStub = { localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, - isOffline: false, duration: null, isVisible: true, isExternal: false, @@ -146,6 +145,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isOffline: false, }), primaryImage: Object.freeze({ @@ -173,7 +173,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -191,6 +190,7 @@ export const assetStub = { { id: 'stack-child-asset-2' } as AssetEntity, ]), duplicateId: null, + isOffline: false, }), image: Object.freeze({ @@ -218,7 +218,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -231,9 +230,50 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), trashed: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + files, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: false, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + isOffline: false, + status: AssetStatus.TRASHED, + }), + + trashedOffline: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -259,7 +299,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -271,8 +310,8 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: true, }), - archived: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -298,7 +337,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -311,6 +349,7 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), external: Object.freeze({ @@ -338,97 +377,19 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - }), - - offline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, - }), - - externalOffline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, }), image1: Object.freeze({ @@ -457,7 +418,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', @@ -467,6 +427,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageFrom2015: Object.freeze({ @@ -490,7 +451,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -505,6 +465,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), video: Object.freeze({ @@ -529,7 +490,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -545,6 +505,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), livePhotoMotionAsset: Object.freeze({ @@ -664,7 +625,6 @@ export const assetStub = { isFavorite: false, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -683,6 +643,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), sidecar: Object.freeze({ id: 'asset-id', @@ -705,7 +666,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -717,6 +677,7 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, + isOffline: false, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', @@ -739,7 +700,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -751,41 +711,7 @@ export const assetStub = { sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, - }), - - readOnly: Object.freeze({ - id: 'read-only-asset', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files: [previewFile], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, isOffline: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - sidecarPath: '/original/path.ext.xmp', - deletedAt: null, - duplicateId: null, }), hasEncodedVideo: Object.freeze({ @@ -810,7 +736,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -824,6 +749,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), missingFileExtension: Object.freeze({ id: 'asset-id', @@ -850,7 +776,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -863,6 +788,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasFileExtension: Object.freeze({ id: 'asset-id', @@ -889,7 +815,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -902,6 +827,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageDng: Object.freeze({ id: 'asset-id', @@ -928,7 +854,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -941,6 +866,7 @@ export const assetStub = { bitsPerSample: 14, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasEmbedding: Object.freeze({ id: 'asset-id-embedding', @@ -967,7 +893,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -982,6 +907,7 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), hasDupe: Object.freeze({ id: 'asset-id-dupe', @@ -1008,7 +934,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -1023,5 +948,6 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), }; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 9ac568af30..ba2f5e10d9 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -25,7 +25,6 @@ export const newAssetRepositoryMock = (): Mocked => { getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), - getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), update: vitest.fn(), diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 3c0ea00c84..1013b4606d 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -13,7 +13,7 @@ export default defineConfig({ lines: 80, statements: 80, branches: 85, - functions: 85, + functions: 80, }, }, server: { diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index db216641d5..d19b428750 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -59,7 +59,6 @@ export let onClose: () => void; const sharedLink = getSharedLink(); - $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; // $: showEditorButton = @@ -87,7 +86,7 @@ {/if} {#if asset.isOffline} - + {/if} {#if asset.livePhotoVideoId} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 9e32927fc3..88ea98778f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -148,12 +148,21 @@ {#if asset.isOffline}

    -
    {$t('asset_offline')}
    -
    +
    + {$t('asset_offline')} +
    +

    - {$t('asset_offline_description')} + {#if $user?.isAdmin} +

    {$t('admin.asset_offline_description')}

    + {:else} + {$t('asset_offline_description')} + {/if}

    +
    +

    {asset.originalPath}

    +
    {/if} diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 76f962f107..8af3f75ade 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,7 +1,7 @@ @@ -508,6 +508,7 @@ onNextAsset={() => navigateAsset('next')} on:close={closeViewer} {sharedLink} + haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} /> {/if} {:else} diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index e2bf6a4b2c..6f0397be98 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -18,7 +18,7 @@ import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; import { t } from 'svelte-i18n'; - const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore; export let onClose = () => {}; @@ -65,6 +65,7 @@ }} /> + ('slideshow-show-progressbar', true); const slideshowDelay = persisted('slideshow-delay', 5, {}); + const slideshowTransition = persisted('slideshow-transition', true); return { restartProgress: { @@ -67,6 +68,7 @@ function createSlideshowStore() { slideshowState, slideshowDelay, showProgressBar, + slideshowTransition, }; } From 03aa34602040ff075cb144b97c19473442c3f0cb Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Sep 2024 22:28:31 +0700 Subject: [PATCH 078/599] fix(mobile): incorrect filename is retrieved during upload (#12990) * fix(mobile): incorrect filename is retrieve during upload * use the same convention to get local id * revert previous change * pr feedback --- mobile/lib/interfaces/asset_media.interface.dart | 3 +++ mobile/lib/repositories/asset_media.repository.dart | 13 +++++++++++++ mobile/lib/services/background.service.dart | 3 +++ mobile/lib/services/backup.service.dart | 9 ++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart index f89a238dd4..2606d5c23c 100644 --- a/mobile/lib/interfaces/asset_media.interface.dart +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -4,4 +4,7 @@ abstract interface class IAssetMediaRepository { Future> deleteAll(List ids); Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 20cf680339..68fffa08a6 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -43,4 +43,17 @@ class AssetMediaRepository implements IAssetMediaRepository { asset.local = local; return asset; } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d06bc86d48..86dfd0c599 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; @@ -368,6 +369,7 @@ class BackgroundService { BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); UserRepository userRepository = UserRepository(db); UserApiRepository userApiRepository = UserApiRepository(apiService.usersApi); @@ -409,6 +411,7 @@ class BackgroundService { albumService, albumMediaRepository, fileMediaRepository, + assetMediaRepository, ); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773..683339f271 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -40,6 +42,7 @@ final backupServiceProvider = Provider( ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); @@ -52,6 +55,7 @@ class BackupService { final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, @@ -60,6 +64,7 @@ class BackupService { this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { @@ -329,7 +334,9 @@ class BackupService { } if (file != null) { - String originalFileName = asset.fileName; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { From 7c15e11efccc1f6f4d7c1da12e932ae5fc058838 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 15:32:16 +0000 Subject: [PATCH 079/599] chore: version v1.116.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index c66d663576..73f0e405ba 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index ba2f846822..f28bbe130f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 992aaa6d4b..9fc474c729 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, { "label": "v1.116.0", "url": "https://v1.116.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 63ad7be469..b451e5dacf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 80bf261a03..38d671d9d5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8d1539a79b..1f953b8827 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.0" +version = "1.116.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6a6454bfe9..43d643d2f6 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 160, - "android.injected.version.name" => "1.116.0", + "android.injected.version.code" => 161, + "android.injected.version.name" => "1.116.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1cc5524c40..a9382cb969 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.0" + version_number: "1.116.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9f2261e03d..e5280e3139 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.116.0 +- API version: 1.116.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a219b6ddb1..ac8294a0a6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.0+160 +version: 1.116.1+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bb0aa83009..b2682dd95a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.0", + "version": "1.116.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3ab9ac0583..95bbddc507 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 45a1fada32..3226f63b19 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 63597d49bc..bf2721f848 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.0 + * 1.116.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 57c8dd7146..53c34aeb32 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 8ba20f6b3b..3817bd5d01 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 172c315570..6a6baca4c2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 938b4dc9cf..9b8d356840 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From dbe542803f6e05b3cc878797677c18bc9739cae6 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 27 Sep 2024 19:07:00 +0200 Subject: [PATCH 080/599] docs: update FAQ CLIP search explanation (#12986) --- docs/docs/FAQ.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 3144b1b9a8..b328d3a047 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? From 789937d4a2409601c35120dff9e454fd735c6a76 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 27 Sep 2024 18:15:44 +0100 Subject: [PATCH 081/599] fix: library pagination to 10k to avoid too many postgres query params (#12993) --- server/src/interfaces/job.interface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 8b6e2c289b..af2726b858 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -116,7 +116,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; export interface IBaseJob { force?: boolean; From 4ed1517e6032839b0bbc062a93b19fbb34e4758e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Sep 2024 01:13:24 +0700 Subject: [PATCH 082/599] chore(mobile): post release task (#12991) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 241cb8ecd9..70bddbf10b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 14fc27b56d..b684804037 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.116.0 + 1.116.1 CFBundleSignature ???? CFBundleVersion - 176 + 177 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 8bbcd5c31e4a227f92864ae2977c4033bc0c50b7 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:17:49 +0000 Subject: [PATCH 083/599] chore: version v1.116.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 73f0e405ba..e508fe843f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f28bbe130f..522a8e593e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 9fc474c729..36a8fed81d 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, { "label": "v1.116.1", "url": "https://v1.116.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b451e5dacf..e7b463b0b2 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 38d671d9d5..7c0025902d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 1f953b8827..840aa93c06 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.1" +version = "1.116.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 43d643d2f6..d1f09a011f 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 161, - "android.injected.version.name" => "1.116.1", + "android.injected.version.name" => "1.116.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index a9382cb969..8dc3676fb7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.1" + version_number: "1.116.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e5280e3139..fecbbf482b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.116.1 +- API version: 1.116.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ac8294a0a6..dc1eb11ca7 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.1+161 +version: 1.116.2+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b2682dd95a..6afd0d792f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.1", + "version": "1.116.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 95bbddc507..72d7a3ec54 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 3226f63b19..41bc3a3b16 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bf2721f848..b1ae5d2876 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.1 + * 1.116.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 53c34aeb32..646a26b1ee 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 3817bd5d01..d481610906 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 6a6baca4c2..a32e96e67f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 9b8d356840..20553759fa 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 7579bc43591dd72bb84b8426786f7834e76e2844 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:07:59 +0000 Subject: [PATCH 084/599] fix(deps): update machine-learning (#12883) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 67 ++++++++++++++---------------- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index e394091ae1..d982962fbc 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu +FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 0754f882f3..195e64ab35 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 84c9ae5d31..5bb1726378 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.114.2" +version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"}, - {file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"}, + {file = "fastapi_slim-0.115.0-py3-none-any.whl", hash = "sha256:27ab44da95b622e68be7a19f06df1960a320b9d94e689b0adfc055bb26ee9be7"}, + {file = "fastapi_slim-0.115.0.tar.gz", hash = "sha256:b4b962ca2aa0a31010dafdad3d4da99d368a5591223304c6fb385712fad7feb6"}, ] [package.dependencies] @@ -2037,22 +2037,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.18.0" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, - {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"}, + {file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.26.4" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2576,18 +2576,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.10" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, + {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2834,29 +2831,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.6" +version = "0.6.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"}, - {file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"}, - {file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"}, - {file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"}, - {file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"}, - {file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"}, - {file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"}, + {file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"}, + {file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"}, + {file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"}, + {file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"}, + {file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"}, + {file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"}, + {file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"}, ] [[package]] From 4248594ac55c2adfcb84918c69ae29d351ca19b3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:10:39 -0400 Subject: [PATCH 085/599] feat(server): better transcoding logs (#13000) * better transcoding logs * pr feedback --- server/src/interfaces/logger.interface.ts | 1 + server/src/interfaces/media.interface.ts | 10 +- server/src/repositories/media.repository.ts | 62 ++- server/src/services/media.service.spec.ts | 405 ++++++++++-------- server/src/services/media.service.ts | 37 +- server/src/utils/media.ts | 1 + .../repositories/logger.repository.mock.ts | 2 +- 7 files changed, 308 insertions(+), 210 deletions(-) diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index ce9a8e64fe..42523afa6b 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -6,6 +6,7 @@ export interface ILoggerRepository { setAppName(name: string): void; setContext(message: string): void; setLogLevel(level: LogLevel): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 459e33fc36..7193684e7a 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -62,6 +62,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -79,6 +83,10 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; @@ -87,6 +95,6 @@ export interface IMediaRepository { getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 5d1aced5eb..d001aa3158 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/enum'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMediaRepository, ImageDimensions, + ProbeOptions, ThumbnailOptions, TranscodeCommand, VideoInfo, @@ -17,10 +18,22 @@ import { import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Instrumentation() @Injectable() export class MediaRepository implements IMediaRepository { @@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, @@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository { width: stream.width || 0, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -156,10 +169,37 @@ export class MediaRepository implements IMediaRepository { } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ce6168408f..ddda8f64fc 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -349,7 +349,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -359,7 +359,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -377,7 +377,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -387,7 +387,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -407,7 +407,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -417,7 +417,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); @@ -430,11 +430,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); @@ -731,21 +731,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -771,11 +772,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -786,11 +787,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -801,11 +802,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -816,11 +817,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -832,11 +833,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -848,11 +849,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -864,11 +865,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -880,11 +881,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -898,11 +899,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -920,11 +921,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -942,11 +943,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -958,11 +959,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -973,11 +974,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1036,11 +1037,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1052,11 +1053,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1068,11 +1069,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1090,11 +1091,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1112,11 +1113,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1128,11 +1129,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1144,11 +1145,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1160,11 +1161,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1176,11 +1177,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1192,11 +1193,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1208,11 +1209,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1224,11 +1225,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1240,7 +1241,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ '-c:v av1', @@ -1255,7 +1256,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1267,11 +1268,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1283,11 +1284,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1299,11 +1300,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1315,11 +1316,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1361,7 +1362,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1382,7 +1383,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1400,11 +1401,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1416,11 +1417,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1432,11 +1433,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1448,11 +1449,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1464,11 +1465,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1482,7 +1483,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', '-hwaccel_output_format cuda', @@ -1491,7 +1492,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1505,7 +1506,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1513,7 +1514,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1526,7 +1527,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1547,7 +1548,7 @@ describe(MediaService.name, () => { '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1566,14 +1567,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1586,11 +1587,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1603,11 +1604,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); @@ -1633,7 +1634,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1645,7 +1646,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1662,7 +1663,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1675,7 +1676,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1691,11 +1692,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1708,7 +1709,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1728,7 +1729,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1741,7 +1742,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1754,7 +1755,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1767,7 +1768,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1780,7 +1781,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1793,14 +1794,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1813,14 +1814,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1833,14 +1834,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1855,14 +1856,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1877,11 +1878,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1904,7 +1905,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', '-hwaccel_output_format drm_prime', @@ -1927,7 +1928,7 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); @@ -1948,11 +1949,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); @@ -1968,11 +1969,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); @@ -1988,7 +1989,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1996,7 +1997,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2012,7 +2013,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2020,7 +2021,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2036,7 +2037,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2044,69 +2045,101 @@ describe(MediaService.name, () => { ), ]), twoPass: false, + }), + ); + }); + + it('should tonemap when policy is required and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should tonemap when policy is optimal and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should count frames for progress when log level is debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + loggerMock.isLevelEnabled.mockReturnValue(true); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, }, ); }); - }); - it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should not count frames for progress when log level is not debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + loggerMock.isLevelEnabled.mockReturnValue(false); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); - it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); - - it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 55a4ee0157..720bef6c76 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -11,6 +11,7 @@ import { AudioCodec, Colorspace, ImageFormat, + LogLevel, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -31,7 +32,13 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { + AudioStreamInfo, + IMediaRepository, + TranscodeCommand, + VideoFormat, + VideoStreamInfo, +} from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -346,7 +353,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -365,12 +374,14 @@ export class MediaService { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -379,16 +390,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + return JobStatus.FAILED; } + this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); command = config.getCommand(target, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(input, output, command); @@ -555,7 +570,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index d80651eece..6f0ab4ef81 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 5f7262c7e5..6342e9e73c 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), From 995f0fda475d40e969190925af455c20abb7a02b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 28 Sep 2024 02:01:04 -0400 Subject: [PATCH 086/599] feat(server): separate quality for thumbnail and preview images (#13006) * allow different thumbnail and preview quality, better config structure * update web and api * wording * remove empty line? --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../system_config_generated_image_dto.dart | 118 ++++++++++++++ .../lib/model/system_config_image_dto.dart | 58 ++----- open-api/immich-openapi-specs.json | 50 +++--- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/config.ts | 23 +-- server/src/dtos/system-config.dto.ts | 38 ++--- server/src/interfaces/media.interface.ts | 9 +- ...7-SeparateQualityForThumbnailAndPreview.ts | 37 +++++ server/src/services/media.service.spec.ts | 8 +- server/src/services/media.service.ts | 27 ++-- server/src/services/person.service.ts | 2 +- .../services/system-config.service.spec.ts | 15 +- .../settings/image/image-settings.svelte | 150 ++++++++++-------- web/src/lib/i18n/en.json | 16 +- 17 files changed, 369 insertions(+), 198 deletions(-) create mode 100644 mobile/openapi/lib/model/system_config_generated_image_dto.dart create mode 100644 server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fecbbf482b..81827a9079 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 22b48df2fb..8be4402980 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -229,6 +229,7 @@ part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3db3297acb..9e38eaf30a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -512,6 +512,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000..2192a7cb0c --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 681a8c00c3..5309f7745c 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -87,11 +65,8 @@ class SystemConfigImageDto { return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -141,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6afd0d792f..1077762ac3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11654,6 +11654,28 @@ ], "type": "object" }, + "SystemConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, "SystemConfigImageDto": { "properties": { "colorspace": { @@ -11662,34 +11684,18 @@ "extractEmbedded": { "type": "boolean" }, - "previewFormat": { - "$ref": "#/components/schemas/ImageFormat" + "preview": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, - "previewSize": { - "minimum": 1, - "type": "integer" - }, - "quality": { - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "thumbnailFormat": { - "$ref": "#/components/schemas/ImageFormat" - }, - "thumbnailSize": { - "minimum": 1, - "type": "integer" + "thumbnail": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" } }, "required": [ "colorspace", "extractEmbedded", - "previewFormat", - "previewSize", - "quality", - "thumbnailFormat", - "thumbnailSize" + "preview", + "thumbnail" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b1ae5d2876..e88f431e8c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; diff --git a/server/src/config.ts b/server/src/config.ts index 1522371487..3317351f9f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,6 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; +import { ImageOutputConfig } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -109,11 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOutputConfig; + preview: ImageOutputConfig; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -259,11 +257,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 4a3ca37691..c12a54cd61 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -473,26 +473,10 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - previewSize!: number; + format!: ImageFormat; @IsInt() @Min(1) @@ -501,6 +485,24 @@ class SystemConfigImageDto { @ApiProperty({ type: 'integer' }) quality!: number; + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + size!: number; +} + +class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; + @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 7193684e7a..64ba6236e8 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,11 +10,14 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOutputConfig { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface ThumbnailOptions extends ImageOutputConfig { + colorspace: string; crop?: CropOptions; processInvalidImages: boolean; } diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000..e02203997f --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ddda8f64fc..c0903fa101 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -285,7 +285,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -307,7 +307,7 @@ describe(MediaService.name, () => { }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -464,7 +464,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -487,7 +487,7 @@ describe(MediaService.name, () => { ); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 720bef6c76..1b69c5acd5 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,7 +10,6 @@ import { AssetType, AudioCodec, Colorspace, - ImageFormat, LogLevel, StorageFolder, TranscodeHWAccel, @@ -175,18 +174,15 @@ export class MediaService { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -195,7 +191,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); if (!previewPath) { return JobStatus.SKIPPED; } @@ -213,9 +209,9 @@ export class MediaService { return JobStatus.SUCCESS; } - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { + private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; + const { size, format, quality } = image[type]; const path = StorageCore.getImagePath(asset, type, format); this.storageCore.ensureFolders(path); @@ -226,13 +222,13 @@ export class MediaService { const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const imageOptions = { format, size, colorspace, - quality: image.quality, + quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }; @@ -274,10 +270,7 @@ export class MediaService { } async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -286,7 +279,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); if (!thumbnailPath) { return JobStatus.SKIPPED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7cb76d1a71..651c8eebee 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -574,7 +574,7 @@ export class PersonService { format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', } as const; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 8b4fb0bc2f..514d8aa0f8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -135,11 +135,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index d6fc814b98..b5e381d5f8 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -24,73 +25,96 @@
    - + + - + - + + - + + - + + + + Date: Sat, 28 Sep 2024 13:47:24 -0400 Subject: [PATCH 087/599] feat(server): generate all thumbnails for an asset in one job (#13012) * wip cleanup add success logs, rename method do thumbhash too fixes fix tests handle `notify` wip refactor refactor * update tests * update sql * pr feedback * remove unused code * formatting --- server/src/config.ts | 6 +- server/src/dtos/system-config.dto.ts | 2 +- server/src/interfaces/asset.interface.ts | 9 +- server/src/interfaces/job.interface.ts | 8 +- server/src/interfaces/media.interface.ts | 44 +- server/src/queries/asset.repository.sql | 24 + server/src/repositories/asset.repository.ts | 9 +- server/src/repositories/job.repository.ts | 4 +- server/src/repositories/media.repository.ts | 68 ++- server/src/services/asset.service.spec.ts | 2 +- server/src/services/asset.service.ts | 2 +- server/src/services/job.service.spec.ts | 34 +- server/src/services/job.service.ts | 49 +- server/src/services/media.service.spec.ts | 567 +++++++++--------- server/src/services/media.service.ts | 221 +++---- server/src/services/microservices.service.ts | 4 +- .../src/services/notification.service.spec.ts | 2 +- server/src/services/notification.service.ts | 2 +- server/src/services/person.service.spec.ts | 47 +- server/src/services/person.service.ts | 6 +- .../repositories/asset.repository.mock.ts | 1 + .../repositories/media.repository.mock.ts | 5 +- 22 files changed, 574 insertions(+), 542 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 3317351f9f..53374d581f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,7 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOutputConfig } from 'src/interfaces/media.interface'; +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -110,8 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnail: ImageOutputConfig; - preview: ImageOutputConfig; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c12a54cd61..039dbd20ff 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto { size!: number; } -class SystemConfigImageDto { +export class SystemConfigImageDto { @Type(() => SystemConfigGeneratedImageDto) @ValidateNested() @IsObject() diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index c6808e3aa8..750a852094 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -194,5 +200,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index af2726b858..aa3090675e 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -37,9 +37,7 @@ export enum JobName { // thumbnails QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -212,9 +210,7 @@ export type JobItem = // Thumbnails | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 64ba6236e8..2bc8ccde36 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,16 +10,44 @@ export interface CropOptions { height: number; } -export interface ImageOutputConfig { +export interface ImageOptions { format: ImageFormat; quality: number; size: number; } -export interface ThumbnailOptions extends ImageOutputConfig { +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { colorspace: string; crop?: CropOptions; processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; + crop?: CropOptions; + preview?: ImageOptions; + processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -78,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -93,8 +126,11 @@ export interface ProbeOptions { export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 6930932584..eda91482bb 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1132,3 +1132,27 @@ RETURNING "id", "createdAt", "updatedAt" + +-- AssetRepository.upsertFiles +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 0ec347ed77..8bca755c32 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cd4c7135be..3f154ee016 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record = { // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, // tags diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d001aa3158..cca87f44f2 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -8,10 +8,12 @@ import sharp from 'sharp'; import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, ProbeOptions, - ThumbnailOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; @@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - if (options.crop) { - pipeline.extract(options.crop); - } - - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -78,6 +73,40 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }); + + if (!options.raw) { + pipeline = pipeline + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace) + .rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + async probe(input: string, options?: ProbeOptions): Promise { const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { @@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository { }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e2d676939..f36d26fa7c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -395,7 +395,7 @@ describe(AssetService.name, () => { it('should run the refresh thumbnails job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b3f824f226..aa88eaf957 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -322,7 +322,7 @@ export class AssetService { } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb4..c2d7a29b9f 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -288,7 +288,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +299,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +326,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +349,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f978f33410..9c73e71cbf 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -281,7 +281,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -295,40 +295,33 @@ export class JobService { break; } - case JobName.GENERATE_PREVIEW: { - const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, - ]; - - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } - } - - await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (!(item.data.notify || item.data.source === 'upload')) { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { break; } const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; + } - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { + const jobs: JobItem[] = [ + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, + ]; + + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); + } + + await this.jobRepository.queueAll(jobs); + if (asset.isVisible) { this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); } + break; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index c0903fa101..88e9f478bd 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -94,7 +94,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -127,7 +127,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -152,7 +152,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -202,7 +202,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -226,7 +226,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -250,7 +250,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -259,10 +259,19 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -270,80 +279,100 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: previewPath, - }); - }); - it('should delete previous preview if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { - size: 1440, - format: ImageFormat.JPEG, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, + }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -361,17 +390,24 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -389,11 +425,18 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -401,8 +444,8 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -424,8 +467,8 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -438,233 +481,207 @@ describe(MediaService.name, () => { ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); + it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, - format, - quality: 80, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format, + size: 1440, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: thumbnailPath, - }); - }, - ); + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); + + it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); it('should delete previous thumbnail if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); - }); - it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + ); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); - - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 1b69c5acd5..71f432e040 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; -import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; + +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -18,7 +19,7 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IBaseJob, @@ -95,18 +96,10 @@ export class MediaService { for (const asset of assets) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); - if (!previewFile || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - - if (!thumbnailFile) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -181,141 +174,127 @@ export class MediaService { return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); - if (!previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else if (asset.type === AssetType.VIDEO) { + generated = await this.generateVideoThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.SKIPPED; } - const { previewFile } = getAssetFiles(asset.files); - if (previewFile && previewFile.path !== previewPath) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + } + + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(previewFile.path); + pathsToDelete.push(previewFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); - - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const { size, format, quality } = image[type]; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } - } - - const assetLabel = asset.isExternal ? asset.originalPath : asset.id; - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, - ); - - return path; - } - - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); - if (!asset) { - return JobStatus.FAILED; - } - - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); - if (!thumbnailPath) { - return JobStatus.SKIPPED; - } - - const { thumbnailFile } = getAssetFiles(asset.files); - if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(thumbnailFile.path); + pathsToDelete.push(thumbnailFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); + } + + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { files: true }); - if (!asset) { - return JobStatus.FAILED; + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const mainVideoStream = this.getMainStream(videoStreams); + if (!mainVideoStream) { + throw new Error(`No video streams found for asset ${asset.id}`); } + const mainAudioStream = this.getMainStream(audioStreams); - const { previewFile } = getAssetFiles(asset.files); - if (!previewFile) { - return JobStatus.FAILED; - } + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); - return JobStatus.SUCCESS; + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 80f1b2be41..0afefefff3 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -68,9 +68,7 @@ export class MicroservicesService { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index a0b9436f75..b3a1e73541 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -155,7 +155,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index bdb23ce700..fdb8257ffa 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -65,7 +65,7 @@ export class NotificationService { @OnEmit({ event: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } @OnEmit({ event: 'asset.trash' }) diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 03da110ac6..c2b8f18221 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -961,12 +961,11 @@ describe(PersonService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -975,6 +974,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', @@ -990,13 +990,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -1005,6 +1004,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1017,12 +1017,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1031,33 +1030,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 651c8eebee..e8e16adb17 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -571,15 +571,15 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.repository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ba2f5e10d9..50fff31e55 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a9866..a809b08162 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), From fa9bb8074cec18cbaa2d1df29e48e8f4cbec5e9d Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 29 Sep 2024 15:22:02 +0700 Subject: [PATCH 088/599] feat(mobile): enhance download operations (#12973) * add packages * create download task * show progress * save video and image * show progress info * live photo wip * download and link live photos * Update list of assets * wip * correct progress * add state to download * revert unncessary change * repository pattern * translation * remove unused code * update method call from repository * remove unused variable * handle multiple livephotos download * remove logging statement * lint * not removing all records --- mobile/assets/i18n/en-US.json | 15 +- mobile/ios/Podfile.lock | 6 + mobile/lib/interfaces/download.interface.dart | 14 ++ mobile/lib/main.dart | 25 ++- .../asset_viewer_page_state.model.dart | 55 ----- .../models/download/download_state.model.dart | 109 ++++++++++ .../download/livephotos_medatada.model.dart | 60 ++++++ mobile/lib/pages/common/download_panel.dart | 150 ++++++++++++++ .../lib/pages/common/gallery_viewer.page.dart | 2 + .../asset_viewer/download.provider.dart | 191 +++++++++++++++++ .../image_viewer_page_state.provider.dart | 99 --------- .../lib/repositories/download.repository.dart | 68 ++++++ mobile/lib/services/download.service.dart | 193 ++++++++++++++++++ mobile/lib/services/image_viewer.service.dart | 117 ----------- mobile/lib/utils/download.dart | 3 + .../asset_viewer/bottom_gallery_bar.dart | 25 ++- .../widgets/asset_viewer/gallery_app_bar.dart | 4 +- .../lib/widgets/forms/login/login_form.dart | 2 +- mobile/pubspec.lock | 12 +- mobile/pubspec.yaml | 3 +- 20 files changed, 868 insertions(+), 285 deletions(-) create mode 100644 mobile/lib/interfaces/download.interface.dart delete mode 100644 mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart create mode 100644 mobile/lib/models/download/download_state.model.dart create mode 100644 mobile/lib/models/download/livephotos_medatada.model.dart create mode 100644 mobile/lib/pages/common/download_panel.dart create mode 100644 mobile/lib/providers/asset_viewer/download.provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart create mode 100644 mobile/lib/repositories/download.repository.dart create mode 100644 mobile/lib/services/download.service.dart delete mode 100644 mobile/lib/services/image_viewer.service.dart create mode 100644 mobile/lib/utils/download.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 324c9069fd..bb4f3efd26 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -588,5 +588,16 @@ "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} \ No newline at end of file + "viewer_unstack": "Un-Stack", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "download_filename": "file: {}", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e19..6a9d34ab83 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_downloader (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -137,6 +140,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + background_downloader: + :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -189,6 +194,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000..dc4f0f57f8 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dc1df746cb..40eda30204 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -72,7 +74,6 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { - debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', @@ -82,11 +83,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); - var immichTheme = ref.watch(immichThemeProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f8..0000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000..edd2fa183e --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000..9c0c7ae4e9 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000..95cefd742a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + Widget build(BuildContext context) { + final progressPercent = (progress * 100).round(); + + getStatusText() { + switch (status) { + case TaskStatus.running: + return 'downloading'.tr(); + case TaskStatus.complete: + return 'download_complete'.tr(); + case TaskStatus.failed: + return 'download_failed'.tr(); + case TaskStatus.canceled: + return 'download_canceled'.tr(); + case TaskStatus.paused: + return 'download_paused'.tr(); + case TaskStatus.enqueued: + return 'download_enqueue'.tr(); + case TaskStatus.notFound: + return 'download_notfound'.tr(); + case TaskStatus.waitingToRetry: + return 'download_waiting_to_retry'.tr(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 1434d1cca5..57c75ca84d 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000..d4aa2823b5 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,191 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImage(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index 631011f200..0000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAsset(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: Platform.isAndroid - ? 'download_sucess_android'.tr() - : 'download_sucess'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000..5b42f66b02 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000..996cbe61f1 --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImage(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final data = await File(filePath).readAsBytes(); + + final Asset? resultAsset = await _fileMediaRepository.saveImage( + data, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + try { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = records.firstWhere( + (record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.image; + }, + ); + + final videoRecord = records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.video; + }); + + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + final resultAsset = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + + return resultAsset != null; + } catch (error) { + debugPrint("Error saving live photo: $error"); + return false; + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index c94244175b..0000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = Provider( - (ref) => ImageViewerService( - ref.watch(apiServiceProvider), - ref.watch(fileMediaRepositoryProvider), - ), -); - -class ImageViewerService { - final ApiService _apiService; - final IFileMediaRepository _fileMediaRepository; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService, this._fileMediaRepository); - - Future downloadAsset(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - Asset? resultAsset; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - resultAsset = await _fileMediaRepository.saveLivePhoto( - image: imageFile, - video: videoFile, - title: asset.fileName, - ); - - if (resultAsset == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - resultAsset = await _fileMediaRepository - .saveImage(imageResponse.bodyBytes, title: asset.fileName); - } - - return resultAsset != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final Asset? resultAsset; - final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - - if (asset.isImage) { - resultAsset = await _fileMediaRepository.saveImage( - res.bodyBytes, - title: asset.fileName, - relativePath: relativePath, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - resultAsset = await _fileMediaRepository.saveVideo( - videoFile, - title: asset.fileName, - relativePath: relativePath, - ); - } - return resultAsset != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000..c701f353a2 --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8b5684d0fa..c3f1390dba 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; @@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { @@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 6de8f5da33..f400224e0a 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget { } handleDownloadAsset() { - ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } return IgnorePointer( diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe195..01b717ef5b 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://192.168.1.16:2283/api'; } login() async { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index aaea00d699..9dadbd1028 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dc1eb11ca7..092b0bb75c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 From 9b309e84c922b2874afdce21961627a2841861c4 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:11:42 -0400 Subject: [PATCH 089/599] docs: update config file (#13041) update config file --- docs/docs/install/config-file.md | 82 +++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b3..b789d8653f 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -20,6 +20,7 @@ The default configuration looks like this: "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` From 2f13db51df15d90221cb4f964936482003da21f2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:14 -0400 Subject: [PATCH 090/599] fix(server): "all" button for facial recognition deleting faces instead of unassigning them (#13042) * unassign faces instead of deleting them * formatting --- server/src/interfaces/person.interface.ts | 10 ++++-- server/src/repositories/person.repository.ts | 36 +++++++++++++------ server/src/services/person.service.spec.ts | 5 +-- server/src/services/person.service.ts | 14 ++------ .../repositories/person.repository.mock.ts | 3 +- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 5708274a6e..65814e0046 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -40,10 +41,12 @@ export interface PeopleStatistics { hidden: number; } -export interface DeleteAllFacesOptions { - sourceType?: string; +export interface DeleteFacesOptions { + sourceType: SourceType; } +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; @@ -59,7 +62,7 @@ export interface IPersonRepository { createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(options: DeleteAllFacesOptions): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; @@ -75,6 +78,7 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; update(person: Partial): Promise; updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2607d2a9ec..0350e8a953 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity'; import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, - DeleteAllFacesOptions, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } @@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { - if (!sourceType) { - return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); - } - + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search'); - if (sourceType === SourceType.MACHINE_LEARNING) { - await this.assetFaceRepository.query('REINDEX INDEX face_index'); - } + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository { const { id } = await this.personRepository.save(person); return this.personRepository.findOneByOrFail({ id }); } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c2b8f18221..5214808de0 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -660,7 +660,7 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ items: [faceStub.face1.person, personStub.randomPerson], @@ -675,7 +675,8 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e8e16adb17..b009696b63 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -276,16 +276,6 @@ export class PersonService { this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { const people = await this.repository.getAllWithoutFaces(); await this.delete(people); @@ -299,7 +289,7 @@ export class PersonService { } if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } @@ -407,7 +397,7 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 77e8ccf010..6ffe7bf97b 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -18,7 +18,7 @@ export const newPersonRepositoryMock = (): Mocked => { updateAll: vitest.fn(), delete: vitest.fn(), deleteAll: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -26,6 +26,7 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), createFaces: vitest.fn(), replaceFaces: vitest.fn(), getFaces: vitest.fn(), From 7adb35e59e5c8e00e5391abfb69bee7acb068bb2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:35 -0400 Subject: [PATCH 091/599] fix(server): `/search/random` failing with certain options (#13040) * fix relation handling, remove pagination * update api, sql * update mock --- mobile/openapi/lib/api/search_api.dart | 9 +- .../openapi/lib/model/random_search_dto.dart | 20 +- open-api/immich-openapi-specs.json | 9 +- open-api/typescript-sdk/src/fetch-client.ts | 3 +- server/src/controllers/search.controller.ts | 2 +- server/src/dtos/search.dto.ts | 18 +- server/src/interfaces/search.interface.ts | 2 +- server/src/queries/search.repository.sql | 189 +++++++++++++++++- server/src/repositories/search.repository.ts | 41 +++- server/src/services/search.service.ts | 16 +- server/src/utils/database.ts | 2 +- .../repositories/search.repository.mock.ts | 1 + 12 files changed, 250 insertions(+), 62 deletions(-) diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 3b981e0ccb..985029f106 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -383,7 +383,7 @@ class SearchApi { /// Parameters: /// /// * [RandomSearchDto] randomSearchDto (required): - Future searchRandom(RandomSearchDto randomSearchDto,) async { + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { final response = await searchRandomWithHttpInfo(randomSearchDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -392,8 +392,11 @@ class SearchApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 419cb451e2..3fcab05bbb 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -29,7 +29,6 @@ class RandomSearchDto { this.libraryId, this.make, this.model, - this.page, this.personIds = const [], this.size, this.state, @@ -145,15 +144,6 @@ class RandomSearchDto { String? model; - /// Minimum value: 1 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - num? page; - List personIds; /// Minimum value: 1 @@ -276,7 +266,6 @@ class RandomSearchDto { other.libraryId == libraryId && other.make == make && other.model == model && - other.page == page && _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && @@ -312,7 +301,6 @@ class RandomSearchDto { (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + - (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + @@ -330,7 +318,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -413,11 +401,6 @@ class RandomSearchDto { json[r'model'] = this.model; } else { // json[r'model'] = null; - } - if (this.page != null) { - json[r'page'] = this.page; - } else { - // json[r'page'] = null; } json[r'personIds'] = this.personIds; if (this.size != null) { @@ -514,7 +497,6 @@ class RandomSearchDto { libraryId: mapValueOfType(json, r'libraryId'), make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), - page: num.parse('${json[r'page']}'), personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1077762ac3..970230f4e3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4615,7 +4615,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchResponseDto" + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" } } }, @@ -10463,10 +10466,6 @@ "nullable": true, "type": "string" }, - "page": { - "minimum": 1, - "type": "number" - }, "personIds": { "items": { "format": "uuid", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e88f431e8c..aa3501079b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -852,7 +852,6 @@ export type RandomSearchDto = { libraryId?: string | null; make?: string; model?: string | null; - page?: number; personIds?: string[]; size?: number; state?: string | null; @@ -2523,7 +2522,7 @@ export function searchRandom({ randomSearchDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SearchResponseDto; + data: AssetResponseDto[]; }>("/search/random", oazapfts.json({ ...opts, method: "POST", diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b6deb2981..9fdb2746fc 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -32,7 +32,7 @@ export class SearchController { @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() - searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { return this.service.searchRandom(auth, dto); } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ddc6c192c5..5c5dce1a11 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -99,12 +99,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - @IsInt() @Min(1) @Max(1000) @@ -170,12 +164,24 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0ba524c00a..63d74a35fb 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,7 +116,6 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; - random?: boolean; } export interface SearchPaginationOptions { @@ -177,6 +176,7 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; + searchRandom(size: number, options: AssetSearchOptions): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58b2999012..cd9a84b016 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -91,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 60694b6bfe..cb80c8d2f1 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -63,22 +64,15 @@ export class SearchRepository implements ISearchRepository { { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - - if (options.random) { - // TODO replace with complicated SQL magic after kysely migration - builder.addSelect('RANDOM() as r').orderBy('r'); - } + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, @@ -87,6 +81,35 @@ export class SearchRepository implements ISearchRepository { }); } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; + } + private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { return builder .select(`${builder.alias}."assetId"`) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index dc6e71f345..c3cc5399c8 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -94,20 +94,10 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } - async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { const userIds = await this.getUserIdsToSearch(auth); - const page = dto.page ?? 1; - const size = dto.size || 250; - const { hasNextPage, items } = await this.searchRepository.searchMetadata( - { page, size }, - { - ...dto, - userIds, - random: true, - }, - ); - - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5f4577f4df..498dd3456b 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -120,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 5426316b65..be0e753e30 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,6 +7,7 @@ export const newSearchRepositoryMock = (): Mocked => { searchSmart: vitest.fn(), searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), + searchRandom: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), getAssetsByCity: vitest.fn(), From 5bcbe77fb6d3b322e08f671d78093f2c3102611a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:02:30 +0100 Subject: [PATCH 092/599] chore(deps): update terraform cloudflare to v4.43.0 (#12860) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index afa00e6067..6419c16dad 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 18d8ff1eb4..74ea6d5816 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index afa00e6067..6419c16dad 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 18d8ff1eb4..74ea6d5816 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } From 95c67949f7a4870cb4c7ce0361ebd7a1c54da092 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 30 Sep 2024 20:51:47 +0700 Subject: [PATCH 093/599] fix(mobile): share to error (#13044) --- mobile/lib/extensions/collection_extensions.dart | 13 ------------- mobile/lib/widgets/asset_grid/multiselect_grid.dart | 6 +----- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index f71b0aacd3..d27c9e9500 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -70,19 +70,6 @@ extension AssetListExtension on Iterable { } return this; } - - /// Filters out offline assets and returns those that are still accessible by the Immich server - /// TODO: isOffline is removed from Immich, so this method is not useful anymore - Iterable nonOfflineOnly({ - void Function()? errorCallback, - }) { - final bool onlyLive = every((e) => false); - if (!onlyLive) { - if (errorCallback != null) errorCallback(); - return where((a) => false); - } - return this; - } } extension SortedByProperty on Iterable { diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 3263373554..14678903ba 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -131,11 +131,7 @@ class MultiselectGrid extends HookConsumerWidget { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet - // Filter offline assets since we cannot fetch their original file - final liveAssets = selection.value.nonOfflineOnly( - errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), - ); - handleShareAssets(ref, context, liveAssets); + handleShareAssets(ref, context, selection.value); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) From a2d457b01d5ae94fa7b3b4862a09265433c29a1b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 10:35:11 -0400 Subject: [PATCH 094/599] refactor(server): events (#13003) * refactor(server): events * chore: better type --------- Co-authored-by: Daniel Dietzler --- e2e/src/api/specs/asset.e2e-spec.ts | 3 +- server/src/app.module.ts | 15 ------ server/src/bin/sync-sql.ts | 2 - server/src/cores/system-config.core.ts | 17 +++---- server/src/decorators.ts | 15 +++--- server/src/enum.ts | 2 +- server/src/interfaces/event.interface.ts | 44 ++++++++++------- server/src/repositories/event.repository.ts | 49 ++++++++++--------- server/src/services/database.service.ts | 4 +- server/src/services/job.service.spec.ts | 46 ++++++----------- server/src/services/job.service.ts | 38 +++++++++----- server/src/services/library.service.spec.ts | 29 ++++++----- server/src/services/library.service.ts | 33 +++++++------ server/src/services/metadata.service.ts | 20 ++++---- server/src/services/microservices.service.ts | 4 +- .../src/services/notification.service.spec.ts | 11 ++++- server/src/services/notification.service.ts | 38 ++++++++------ server/src/services/server.service.ts | 4 +- server/src/services/smart-info.service.ts | 16 +++--- .../services/storage-template.service.spec.ts | 16 +++--- .../src/services/storage-template.service.ts | 23 +++++---- server/src/services/storage.service.ts | 4 +- .../services/system-config.service.spec.ts | 14 +++--- server/src/services/system-config.service.ts | 40 +++++++-------- server/src/services/trash.service.ts | 4 +- server/src/services/version.service.ts | 10 ++-- server/src/utils/events.ts | 16 +++--- .../repositories/event.repository.mock.ts | 2 +- 28 files changed, 260 insertions(+), 259 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf..4dd02ec69f 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -76,7 +76,6 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; - let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -236,7 +235,7 @@ describe('/asset', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); // asset faces - facesAsset = await utils.createAsset(admin.accessToken, { + const facesAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'portrait.jpg', bytes: await readFile(facesAssetFilepath), diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9446010127..55b9babcb4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import _ from 'lodash'; @@ -42,7 +41,6 @@ const imports = [ BullModule.registerQueue(...bullQueues), ClsModule.forRoot(clsConfig), ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRootAsync({ inject: [ModuleRef], @@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553..92c3cc1103 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -85,7 +84,6 @@ class SqlGenerator { logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), ], providers: [...repositories, AuthService, SchedulerRegistry], diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 8ed53344cc..816ab00446 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; @@ -24,8 +23,6 @@ export class SystemConfigCore { private config: SystemConfig | null = null; private lastUpdated: number | null = null; - config$ = new Subject(); - private constructor( private repository: ISystemMetadataRepository, private logger: ILoggerRepository, @@ -42,6 +39,11 @@ export class SystemConfigCore { instance = null; } + invalidateCache() { + this.config = null; + this.lastUpdated = null; + } + async getConfig({ withCache }: { withCache: boolean }): Promise { if (!withCache || !this.config) { const lastUpdated = this.lastUpdated; @@ -74,14 +76,7 @@ export class SystemConfigCore { await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); + return this.getConfig({ withCache: false }); } isUsingConfigFile() { diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 9b6910391a..2782368239 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { MetadataKey } from 'src/enum'; -import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -133,15 +131,14 @@ export interface GenerateSqlQueries { /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type EmitConfig = { - event: EmitEvent; +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/enum.ts b/server/src/enum.ts index e0c1e27859..757291b118 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -310,7 +310,7 @@ export enum MetadataKey { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', + EVENT_CONFIG = 'event_config', } export enum RouteKey { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bc5ce90f40..02027d87e6 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -type EmitEventMap = { +type EventMap = { // app events 'app.bootstrap': ['api' | 'microservices']; 'app.shutdown': []; // config events - 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events @@ -43,12 +49,18 @@ type EmitEventMap = { // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EmitEvent = keyof EmitEventMap; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EmitEventMap[T][0]; -export type ArgsOf = EmitEventMap[T]; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -82,19 +94,15 @@ export interface ClientEventMap { [ClientEvent.SESSION_DELETE]: string; } -export enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', -} - -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitHandler): void; - emit(event: T, ...args: ArgsOf): Promise; + on(item: EventItem): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user @@ -105,7 +113,7 @@ export interface IEventRepository { */ clientBroadcast(event: E, data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 9aa12e15dd..a8b2fa67c3 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -13,16 +12,17 @@ import { ArgsOf, ClientEventMap, EmitEvent, - EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; -type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @Instrumentation() @WebSocketGateway({ @@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); @@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect afterInit(server: Server) { this.logger.log('Initialized websocket server'); - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; - } - - server.on(event, (data: unknown) => { + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -72,7 +67,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect if (auth.session) { await client.join(auth.session.id); } - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitHandler): void { + on(item: EventItem): void { + const event = item.event; + if (!this.emitHandlers[event]) { this.emitHandlers[event] = []; } - this.emitHandlers[event].push(handler); + this.emitHandlers[event].push(item); } async emit(event: T, ...args: ArgsOf): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } @@ -108,9 +114,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.server?.emit(event, data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ee6176115b..9ba190d30a 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, @@ -74,7 +74,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'app.bootstrap', priority: -200 }) + @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c2d7a29b9f..8d7c15073d 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { @@ -60,6 +59,19 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap('microservices'); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -239,36 +251,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 9c73e71cbf..68da13a8e4 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { @Injectable() export class JobService { private configCore: SystemConfigCore; + private isMicroservices = false; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -59,6 +61,28 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === 'microservices'; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } @@ -209,18 +233,6 @@ export class JobService { } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8b14c76cbc..bcf0f1d0b5 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType } from 'src/enum'; @@ -81,22 +80,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.onBootstrap(); - expect(systemMock.get).toHaveBeenCalled(); - expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: true }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 52b786089c..b8b478531f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -61,7 +61,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -83,19 +83,24 @@ export class LibraryService { if (this.watchLibraries) { await this.watchAll(); } - - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); - - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.watchLock) { + return; + } + + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { @@ -185,7 +190,7 @@ export class LibraryService { } } - @OnEmit({ event: 'app.shutdown' }) + @OnEvent({ name: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 224ef03b3b..9499a4bdd9 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -132,7 +132,7 @@ export class MetadataService { ); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -141,7 +141,12 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'config.update' }) + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.repository.teardown(); + } + + @OnEvent({ name: 'config.update' }) async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -164,11 +169,6 @@ export class MetadataService { } } - @OnEmit({ event: 'app.shutdown' }) - async onShutdown() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -333,12 +333,12 @@ export class MetadataService { return this.processSidecar(id, false); } - @OnEmit({ event: 'asset.tag' }) + @OnEvent({ name: 'asset.tag' }) async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } - @OnEmit({ event: 'asset.untag' }) + @OnEvent({ name: 'asset.untag' }) async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 0afefefff3..23604b6ef6 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; @@ -43,7 +43,7 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index b3a1e73541..106f0be082 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -100,6 +100,15 @@ describe(NotificationService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index fdb8257ffa..626e536c40 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -43,7 +43,13 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'config.validate', priority: -100 }) + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); + } + + @OnEvent({ name: 'config.validate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( @@ -58,74 +64,74 @@ export class NotificationService { } } - @OnEmit({ event: 'asset.hide' }) + @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } - @OnEmit({ event: 'asset.show' }) + @OnEvent({ name: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } - @OnEmit({ event: 'asset.trash' }) + @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); } - @OnEmit({ event: 'asset.delete' }) + @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); } - @OnEmit({ event: 'assets.trash' }) + @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); } - @OnEmit({ event: 'assets.restore' }) + @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); } - @OnEmit({ event: 'stack.create' }) + @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.update' }) + @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.delete' }) + @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stacks.delete' }) + @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'user.signup' }) + @OnEvent({ name: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'album.update' }) + @OnEvent({ name: 'album.update' }) async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'album.invite' }) + @OnEvent({ name: 'album.invite' }) async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } - @OnEmit({ event: 'session.delete' }) + @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index a192c2f308..708fe32db5 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,7 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index a75594100f..ef7865d25c 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; @@ -39,7 +39,7 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -49,7 +49,12 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); @@ -60,11 +65,6 @@ export class SmartInfoService { } } - @OnEmit({ event: 'config.update' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - await this.init(newConfig, oldConfig); - } - private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { if (!isSmartSearchEnabled(newConfig.machineLearning)) { return; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index e8e222c7b2..36a50c41bd 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,6 +1,5 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); describe('onConfigValidate', () => { @@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); - it('Should use handlebar if condition for album', async () => { + + it('should use handlebar if condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); - it('Should use handlebar else condition for album', async () => { + + it('should use handlebar else condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); + it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => { originalPath: newPath, }); }); + it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 30d0eb575f..33b08efc9b 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -3,7 +3,6 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -15,7 +14,7 @@ import { } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -76,7 +75,6 @@ export class StorageTemplateService { ) { this.logger.setContext(StorageTemplateService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -88,7 +86,16 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); @@ -282,14 +289,6 @@ export class StorageTemplateService { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6d15f097d3..b32e48ea49 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; @@ -21,7 +21,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 514d8aa0f8..ac517bb3ff 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,14 +6,13 @@ import { CQMode, ImageFormat, LogLevel, - SystemMetadataKey, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => { }); describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + expect(eventMock.emit).toHaveBeenCalledWith( + 'config.update', + expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), + ); }); it('should throw an error if a config file is in use', async () => { diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 8a7f9123e0..100ab6f47c 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -13,10 +13,10 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { toPlainObject } from 'src/utils/object'; @@ -32,13 +32,12 @@ export class SystemConfigService { ) { this.logger.setContext(SystemConfigService.name); this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'app.bootstrap', priority: -100 }) + @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); + await this.eventRepository.emit('config.update', { newConfig: config }); } async getConfig(): Promise { @@ -50,7 +49,18 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const envLevel = this.getEnvLogLevel(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + this.core.invalidateCache(); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); @@ -73,9 +83,6 @@ export class SystemConfigService { const newConfig = await this.core.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); @@ -101,19 +108,6 @@ export class SystemConfigService { return theme.customCss; } - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - private getEnvLogLevel() { return process.env.IMMICH_LOG_LEVEL as LogLevel; } diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 88340f7d7c..51771d38a2 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,5 +1,5 @@ import { Inject } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; @@ -54,7 +54,7 @@ export class TrashService { return { count }; } - @OnEmit({ event: 'assets.delete' }) + @OnEvent({ name: 'assets.delete' }) async onAssetsDelete() { await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 468e8c9bdd..0c7ae52cac 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } @@ -90,8 +90,8 @@ export class VersionService { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index f5b079dea4..fbac554578 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,6 +1,6 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { EmitConfig } from 'src/decorators'; +import { EventConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { services } from 'src/services'; @@ -9,6 +9,7 @@ type Item = { event: T; handler: EmitHandler; priority: number; + server: boolean; label: string; }; @@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { continue; } - const options = reflector.get(MetadataKey.ON_EMIT_CONFIG, handler); - if (!options) { + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { continue; } items.push({ - event: options.event, - priority: options.priority || 0, + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, handler: handler.bind(instance), label: `${Service.name}.${handler.name}`, }); @@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { const handlers = _.orderBy(items, ['priority'], ['asc']); // register by priority - for (const { event, handler } of handlers) { - repository.on(event as EmitEvent, handler); + for (const handler of handlers) { + repository.on(handler); } return handlers; diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599..78c62e95f2 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + on: vitest.fn() as any, emit: vitest.fn() as any, clientSend: vitest.fn(), clientBroadcast: vitest.fn(), From 15c04d3056e1342bef34426b9bd2b1d15316d7d7 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:37:30 +0200 Subject: [PATCH 095/599] refactor(mobile): DB repository for asset, backup, sync service (#12953) * refactor(mobile): DB repository for asset, backup, sync service * review feedback * fix bug found by Alex --------- Co-authored-by: Alex --- mobile/analysis_options.yaml | 18 +- mobile/lib/interfaces/album.interface.dart | 30 +- mobile/lib/interfaces/asset.interface.dart | 51 ++- mobile/lib/interfaces/backup.interface.dart | 13 +- mobile/lib/interfaces/database.interface.dart | 3 + mobile/lib/interfaces/etag.interface.dart | 14 + .../lib/interfaces/exif_info.interface.dart | 5 +- mobile/lib/interfaces/user.interface.dart | 21 +- mobile/lib/providers/asset.provider.dart | 22 +- .../lib/providers/backup/backup.provider.dart | 9 +- .../backup/manual_upload.provider.dart | 10 +- .../repositories/activity_api.repository.dart | 4 +- mobile/lib/repositories/album.repository.dart | 88 +++-- .../repositories/album_api.repository.dart | 7 +- ...pi.repository.dart => api.repository.dart} | 4 +- mobile/lib/repositories/asset.repository.dart | 215 +++++++++---- .../repositories/asset_api.repository.dart | 5 +- .../lib/repositories/backup.repository.dart | 34 +- .../lib/repositories/database.repository.dart | 28 ++ mobile/lib/repositories/etag.repository.dart | 29 ++ .../repositories/exif_info.repository.dart | 23 +- .../repositories/partner_api.repository.dart | 4 +- .../repositories/person_api.repository.dart | 4 +- mobile/lib/repositories/user.repository.dart | 52 ++- .../lib/repositories/user_api.repository.dart | 5 +- mobile/lib/services/album.service.dart | 27 +- mobile/lib/services/asset.service.dart | 72 +++-- mobile/lib/services/background.service.dart | 70 ++-- mobile/lib/services/backup.service.dart | 30 +- .../services/backup_verification.service.dart | 6 +- mobile/lib/services/hash.service.dart | 4 +- mobile/lib/services/stack.service.dart | 3 +- mobile/lib/services/sync.service.dart | 302 +++++++++--------- .../modules/shared/sync_service_test.dart | 96 ++++-- mobile/test/repository.mocks.dart | 6 + mobile/test/services/album.service_test.dart | 9 +- 36 files changed, 873 insertions(+), 450 deletions(-) create mode 100644 mobile/lib/interfaces/database.interface.dart create mode 100644 mobile/lib/interfaces/etag.interface.dart rename mobile/lib/repositories/{base_api.repository.dart => api.repository.dart} (71%) create mode 100644 mobile/lib/repositories/database.repository.dart create mode 100644 mobile/lib/repositories/etag.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6a7d7a6b4d..80514f1603 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,19 +64,19 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart - # acceptable exceptions for the time being + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart - - lib/routing/router.dart - - lib/utils/{db,migration,renderlist_generator}.dart - - test/**.dart - # refactor to make the providers and services testable - lib/pages/common/album_asset_selection.page.dart - - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/routing/router.dart + - lib/services/immich_logger.service.dart # not really a service... more a util + - lib/utils/{db,migration,renderlist_generator}.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart + - test/**.dart + # refactor the remaining providers + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index c2ba650b6f..ba188f1270 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -1,21 +1,43 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAlbumRepository { - Future count({bool? local}); +abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); - Future getById(int id); + + Future get(int id); + Future getByName( String name, { bool? shared, bool? remote, }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + Future update(Album album); + Future delete(int albumId); - Future> getAll({bool? shared}); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); } + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 0d2dcfa1b5..5aec594eb1 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,27 +1,62 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAssetRepository { +abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); - Future> getAllByRemoteId(Iterable ids); - Future> getByAlbum(Album album, {User? notOwnedBy}); - Future deleteById(List ids); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }); + + Future update(Asset asset); + Future> updateAll(List assets); + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }); Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); } + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart index e343a9d390..c32199a58f 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup.interface.dart @@ -1,5 +1,16 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAll({BackupAlbumSort? sort}); -abstract interface class IBackupRepository { Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); } + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000..5645d15c47 --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000..e567235d1b --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IETagRepository implements IDatabaseRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart index fa8ca08f9d..86608c26d0 100644 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -1,9 +1,12 @@ import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IExifInfoRepository { +abstract interface class IExifInfoRepository implements IDatabaseRepository { Future get(int id); Future update(ExifInfo exifInfo); + Future> updateAll(List exifInfos); + Future delete(int id); } diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 828a7b2398..e6175a7dc9 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -1,8 +1,23 @@ import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IUserRepository { - Future> getByIds(List ids); +abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); - Future> getAll({bool self = true}); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sortBy}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + Future update(User user); + + Future deleteById(List ids); + + Future me(); } + +enum UserSort { id } diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a2c3987aa8..c7e75df79b 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 0885f35f77..dc6d2f7cc8 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -45,6 +47,7 @@ class BackupNotifier extends StateNotifier { this._db, this._albumMediaRepository, this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -95,6 +98,7 @@ class BackupNotifier extends StateNotifier { final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -255,9 +259,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -767,6 +771,7 @@ final backupProvider = ref.watch(dbProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(backupRepositoryProvider), ref, ); }); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 0cf159bfdd..192126f085 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,8 +6,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -25,7 +27,6 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -36,6 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -45,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -206,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - _backupService.selectedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.select); final excludedBackupAlbums = - _backupService.excludedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index 0b1b4d99f3..8da3759709 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final activityApiRepositoryProvider = Provider( (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), ); -class ActivityApiRepository extends BaseApiRepository +class ActivityApiRepository extends ApiRepository implements IActivityApiRepository { final ActivitiesApi _api; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 08c939aa6c..35f5cae327 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -4,32 +4,36 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider))); -class AlbumRepository implements IAlbumRepository { - final Isar _db; - - AlbumRepository( - this._db, - ); +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { + AlbumRepository(super.db); @override Future count({bool? local}) { - if (local == true) return _db.albums.where().localIdIsNotNull().count(); - if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); - return _db.albums.count(); + final baseQuery = db.albums.where(); + final QueryBuilder query; + switch (local) { + case null: + query = baseQuery.noOp(); + case true: + query = baseQuery.localIdIsNotNull(); + case false: + query = baseQuery.remoteIdIsNotNull(); + } + return query.count(); } @override - Future create(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future create(Album album) => txn(() => db.albums.store(album)); @override Future getByName(String name, {bool? shared, bool? remote}) { - var query = _db.albums.filter().nameEqualTo(name); + var query = db.albums.filter().nameEqualTo(name); if (shared != null) { query = query.sharedEqualTo(shared); } @@ -42,37 +46,61 @@ class AlbumRepository implements IAlbumRepository { } @override - Future update(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future update(Album album) => txn(() => db.albums.store(album)); @override - Future delete(int albumId) => - _db.writeTxn(() => _db.albums.delete(albumId)); + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); @override - Future> getAll({bool? shared}) { - final baseQuery = _db.albums.filter(); - QueryBuilder? query; - if (shared != null) { - query = baseQuery.sharedEqualTo(true); + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); } - return query?.findAll() ?? _db.albums.where().findAll(); + QueryBuilder filterQuery = + afterWhere.filter().noOp(); + if (shared != null) { + filterQuery = filterQuery.sharedEqualTo(true); + } + if (ownerId != null) { + filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); + } + final QueryBuilder query; + switch (sortBy) { + case null: + query = filterQuery.noOp(); + case AlbumSort.remoteId: + query = filterQuery.sortByRemoteId(); + case AlbumSort.localId: + query = filterQuery.sortByLocalId(); + } + return query.findAll(); } @override - Future getById(int id) => _db.albums.get(id); + Future get(int id) => db.albums.get(id); @override Future removeUsers(Album album, List users) => - _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + txn(() => album.sharedUsers.update(unlink: users)); @override Future addAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(link: assets)); + txn(() => album.assets.update(link: assets)); @override Future removeAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(unlink: assets)); + txn(() => album.assets.update(unlink: assets)); @override Future recalculateMetadata(Album album) async { @@ -82,4 +110,12 @@ class AlbumRepository implements IAlbumRepository { await album.assets.filter().updatedAtProperty().max(); return album; } + + @override + Future addUsers(Album album, List users) => + txn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 0e27e44684..5d0b56dc78 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -4,15 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository extends BaseApiRepository - implements IAlbumApiRepository { +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @@ -26,7 +25,7 @@ class AlbumApiRepository extends BaseApiRepository @override Future> getAll({bool? shared}) async { final dtos = await checkNull(_api.getAllAlbums(shared: shared)); - return dtos.map(_toAlbum).toList().cast(); + return dtos.map(_toAlbum).toList(); } @override diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/api.repository.dart similarity index 71% rename from mobile/lib/repositories/base_api.repository.dart rename to mobile/lib/repositories/api.repository.dart index 418cba84f8..b454c77f9b 100644 --- a/mobile/lib/repositories/base_api.repository.dart +++ b/mobile/lib/repositories/api.repository.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:immich_mobile/constants/errors.dart'; -abstract class BaseApiRepository { - @protected +abstract class ApiRepository { Future checkNull(Future future) async { final response = await future; if (response == null) throw NoResponseDtoError(); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 087344302a..eaaafd3045 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -5,78 +5,145 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider))); -class AssetRepository implements IAssetRepository { - final Isar _db; - - AssetRepository( - this._db, - ); +class AssetRepository extends DatabaseRepository implements IAssetRepository { + AssetRepository(super.db); @override - Future> getByAlbum(Album album, {User? notOwnedBy}) { + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { var query = album.assets.filter(); - if (notOwnedBy != null) { - query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); } - return query.findAll(); + if (ownerId != null) { + query = query.ownerIdEqualTo(ownerId); + } + + switch (state) { + case null: + break; + case AssetState.local: + query = query.remoteIdIsNull(); + case AssetState.remote: + query = query.localIdIsNull(); + case AssetState.merged: + query = query.localIdIsNotNull().remoteIdIsNotNull(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); + } + + return sortedQuery.findAll(); } @override - Future deleteById(List ids) => - _db.writeTxn(() => _db.assets.deleteAll(ids)); + Future deleteById(List ids) => txn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); @override - Future getByRemoteId(String id) => _db.assets.getByRemoteId(id); + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); @override - Future> getAllByRemoteId(Iterable ids) => - _db.assets.getAllByRemoteId(ids); + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + AssetState? state, + ) { + final query = db.assets.remote(ids).filter(); + switch (state) { + case null: + return query.noOp(); + case AssetState.local: + return query.remoteIdIsNull(); + case AssetState.remote: + return query.localIdIsNull(); + case AssetState.merged: + return query.localIdIsNotEmpty().remoteIdIsNotNull(); + } + } @override Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }) { - if (remote == null) { - return _db.assets - .where() - .ownerIdEqualToAnyChecksum(ownerId) - .limit(limit) - .findAll(); - } - final QueryBuilder query; - if (remote) { - query = _db.assets - .where() - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - } else { - query = _db.assets - .where() - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); + final baseQuery = db.assets.where(); + final QueryBuilder filteredQuery; + switch (state) { + case null: + filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); + case AssetState.local: + filteredQuery = baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.remote: + filteredQuery = baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.merged: + filteredQuery = baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(); } - return query.limit(limit).findAll(); + final QueryBuilder query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); + } + + return limit == null ? query.findAll() : query.limit(limit).findAll(); } @override Future> updateAll(List assets) async { - await _db.writeTxn(() => _db.assets.putAll(assets)); + await txn(() => db.assets.putAll(assets)); return assets; } @@ -84,16 +151,20 @@ class AssetRepository implements IAssetRepository { Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }) { + final baseQuery = db.assets.where(); final QueryBuilder query; - if (remote == null) { - query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); - } else if (remote) { - query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); - } else { - query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + switch (state) { + case null: + query = baseQuery.noOp(); + case AssetState.local: + query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); + case AssetState.remote: + query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); + case AssetState.merged: + query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); } return _getMatchesImpl(query, ownerId, assets, limit); } @@ -101,16 +172,50 @@ class AssetRepository implements IAssetRepository { @override Future> getDeviceAssetsById(List ids) => Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); @override - Future upsertDeviceAssets(List deviceAssets) => - _db.writeTxn( + Future upsertDeviceAssets(List deviceAssets) => txn( () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) - : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), ); + + @override + Future update(Asset asset) async { + await txn(() => asset.put(db)); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index eb796f6c6b..54d57c4dfc 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -12,8 +12,7 @@ final assetApiRepositoryProvider = Provider( ), ); -class AssetApiRepository extends BaseApiRepository - implements IAssetApiRepository { +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { final AssetsApi _api; final SearchApi _searchApi; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index c9d93f7877..61997ff23a 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -2,19 +2,41 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final backupRepositoryProvider = Provider((ref) => BackupRepository(ref.watch(dbProvider))); -class BackupRepository implements IBackupRepository { - final Isar _db; +class BackupRepository extends DatabaseRepository implements IBackupRepository { + BackupRepository(super.db); - BackupRepository( - this._db, - ); + @override + Future> getAll({BackupAlbumSort? sort}) { + final baseQuery = db.backupAlbums.where(); + final QueryBuilder query; + switch (sort) { + case null: + query = baseQuery.noOp(); + case BackupAlbumSort.id: + query = baseQuery.sortById(); + } + return query.findAll(); + } @override Future> getIdsBySelection(BackupSelection backup) => - _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); + + @override + Future updateAll(List backupAlbums) => + txn(() => db.backupAlbums.putAll(backupAlbums)); } diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000..f9ee1426bb --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:isar/isar.dart'; + +/// copied from Isar; needed to check if an async transaction is already active +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { + final Isar db; + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000..9921b69f5e --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DatabaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + txn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart index a165e98bdb..3ddb50104b 100644 --- a/mobile/lib/repositories/exif_info.repository.dart +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -2,27 +2,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; final exifInfoRepositoryProvider = Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); -class ExifInfoRepository implements IExifInfoRepository { - final Isar _db; - - ExifInfoRepository( - this._db, - ); +class ExifInfoRepository extends DatabaseRepository + implements IExifInfoRepository { + ExifInfoRepository(super.db); @override - Future delete(int id) => _db.exifInfos.delete(id); + Future delete(int id) => txn(() => db.exifInfos.delete(id)); @override - Future get(int id) => _db.exifInfos.get(id); + Future get(int id) => db.exifInfos.get(id); @override Future update(ExifInfo exifInfo) async { - await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + await txn(() => db.exifInfos.put(exifInfo)); return exifInfo; } + + @override + Future> updateAll(List exifInfos) async { + await txn(() => db.exifInfos.putAll(exifInfos)); + return exifInfos; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 3419a2bc77..0b3d164ca3 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final partnerApiRepositoryProvider = Provider( @@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider( ), ); -class PartnerApiRepository extends BaseApiRepository +class PartnerApiRepository extends ApiRepository implements IPartnerApiRepository { final PartnersApi _api; diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 8071c33dc2..d324a03edb 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -1,14 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final personApiRepositoryProvider = Provider( (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), ); -class PersonApiRepository extends BaseApiRepository +class PersonApiRepository extends ApiRepository implements IPersonApiRepository { final PeopleApi _api; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 796b1f421b..fb4df84fe7 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -3,37 +3,61 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(dbProvider))); -class UserRepository implements IUserRepository { - final Isar _db; - - UserRepository( - this._db, - ); +class UserRepository extends DatabaseRepository implements IUserRepository { + UserRepository(super.db); @override Future> getByIds(List ids) async => - (await _db.users.getAllById(ids)).cast(); + (await db.users.getAllById(ids)).nonNulls.toList(); @override - Future get(String id) => _db.users.getById(id); + Future get(String id) => db.users.getById(id); @override - Future> getAll({bool self = true}) { - if (self) { - return _db.users.where().findAll(); - } + Future> getAll({bool self = true, UserSort? sortBy}) { + final baseQuery = db.users.where(); final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); + final QueryBuilder afterWhere = + self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); + final QueryBuilder query; + switch (sortBy) { + case null: + query = afterWhere.noOp(); + case UserSort.id: + query = afterWhere.sortById(); + } + return query.findAll(); } @override Future update(User user) async { - await _db.writeTxn(() => _db.users.put(user)); + await txn(() => db.users.put(user)); return user; } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await txn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart index ffc50ae4c3..9641c4e0e6 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/repositories/user_api.repository.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final userApiRepositoryProvider = Provider( @@ -14,8 +14,7 @@ final userApiRepositoryProvider = Provider( ), ); -class UserApiRepository extends BaseApiRepository - implements IUserApiRepository { +class UserApiRepository extends ApiRepository implements IUserApiRepository { final UsersApi _api; UserApiRepository(this._api); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index dd021e698e..091049edb5 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -243,14 +243,15 @@ class AlbumService { int albumId, { List add = const [], List remove = const [], - }) async { - final album = await _albumRepository.getById(albumId); - if (album == null) return; - await _albumRepository.addAssets(album, add); - await _albumRepository.removeAssets(album, remove); - await _albumRepository.recalculateMetadata(album); - await _albumRepository.update(album); - } + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); Future addAdditionalUserToAlbum( List sharedUserIds, @@ -285,20 +286,20 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final user = Store.get(StoreKey.currentUser); - if (album.owner.value?.isarId == user.isarId) { + final userId = Store.get(StoreKey.currentUser).isarId; + if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); await _albumRepository.delete(album.id); final List albums = await _albumRepository.getAll(shared: true); final List existing = []; for (Album album in albums) { existing.addAll( - await _assetRepository.getByAlbum(album, notOwnedBy: user), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = @@ -357,7 +358,7 @@ class AlbumService { album.sharedUsers.remove(user); await _albumRepository.removeUsers(album, [user]); - final a = await _albumRepository.getById(album.id); + final a = await _albumRepository.get(album.id); // trigger watcher await _albumRepository.update(a!); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 262040026e..b2cad4dc82 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,27 +1,30 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; @@ -29,48 +32,54 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), - ref.watch(dbProvider), ), ); class AssetService { final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( this._assetApiRepository, + this._assetRepository, this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, this._backupService, this._albumService, - this._db, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -175,7 +184,7 @@ class AssetService { /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _db.exifInfos.get(a.id); + a.exifInfo ??= await _exifInfoRepository.get(a.id); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { @@ -185,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -214,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -230,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -250,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -278,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -307,10 +316,10 @@ class AssetService { Future syncUploadedAssetToAlbums() async { try { - final [selectedAlbums, excludedAlbums] = await Future.wait([ - _backupService.selectedAlbumsQuery().findAll(), - _backupService.excludedAlbumsQuery().findAll(), - ]); + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); final candidates = await _backupService.buildUploadCandidates( selectedAlbums, @@ -319,12 +328,11 @@ class AssetService { ); await refreshRemoteAssets(); - final remoteAssets = await _db.assets - .where() - .localIdIsNotNull() - .filter() - .remoteIdIsNotNull() - .findAll(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); /// Map Map> assetToAlbums = {}; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 86dfd0c599..3959e2a6ed 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; @@ -18,6 +19,8 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; @@ -38,7 +41,6 @@ import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -357,7 +359,7 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); @@ -366,7 +368,9 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupAlbumRepository = BackupRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -382,11 +386,15 @@ class BackgroundService { EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( - db, hashService, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, ); UserService userService = UserService( partnerApiRepository, @@ -400,22 +408,24 @@ class BackgroundService { entityService, albumRepository, assetRepository, - backupAlbumRepository, + backupRepository, albumMediaRepository, albumApiRepository, ); BackupService backupService = BackupService( apiService, - db, settingService, albumService, albumMediaRepository, fileMediaRepository, + assetRepository, assetMediaRepository, ); - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } @@ -433,28 +443,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 683339f271..a0b6bf16c2 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,9 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -20,14 +20,13 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; @@ -37,11 +36,11 @@ import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(assetMediaRepositoryProvider), ), ); @@ -49,21 +48,21 @@ final backupServiceProvider = Provider( class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, - this._db, this._appSetting, this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetRepository, this._assetMediaRepository, ); @@ -78,24 +77,17 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index da9d8da164..82cfb8347a 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -34,19 +34,19 @@ class BackupVerificationService { final owner = Store.get(StoreKey.currentUser).isarId; final List onlyLocal = await _assetRepository.getAll( ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); final List remoteMatches = await _assetRepository.getMatches( assets: onlyLocal, ownerId: owner, - remote: true, + state: AssetState.remote, limit: limit, ); final List localMatches = await _assetRepository.getMatches( assets: remoteMatches, ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 3827e421e6..bb19340d2f 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -130,7 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _assetRepository.upsertDeviceAssets(validHashes); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 8bff21fef6..1ca56ff279 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -61,7 +61,8 @@ class StackService { removeAssets.add(asset); } - await _assetRepository.updateAll(removeAssets); + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); } catch (error) { debugPrint("Error while deleting stack: $error"); } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index c3f927fc93..e23c2d1b1b 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,48 +5,66 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; final syncServiceProvider = Provider( (ref) => SyncService( - ref.watch(dbProvider), ref.watch(hashServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), ), ); class SyncService { - final Isar _db; final HashService _hashService; final EntityService _entityService; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); SyncService( - this._db, this._hashService, this._entityService, this._albumMediaRepository, this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, ); // public methods: @@ -119,7 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; @@ -141,9 +159,9 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); }); } return changes; @@ -152,15 +170,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -175,9 +193,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -198,7 +216,7 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; @@ -206,23 +224,21 @@ class SyncService { /// Deletes remote-only assets, updates merged assets to be local-only Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; } + await _assetRepository.updateAll(merged); }); } @@ -237,12 +253,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -259,11 +270,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -278,9 +288,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -289,12 +299,12 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database @@ -305,15 +315,13 @@ class SyncService { ) async { remoteAlbums.sortBy((e) => e.remoteId!); - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); + final User me = await _userRepository.me(); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + shared: isShared ? true : null, + ownerId: isShared ? null : me.isarId, + sortBy: AlbumSort.remoteId, + ); assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); final List toDelete = []; @@ -333,10 +341,7 @@ class SyncService { if (isShared && toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -360,8 +365,11 @@ class SyncService { // i.e. it will always be null. Save it here. final originalDto = dto; dto = await _albumApiRepository.get(dto.remoteId!); - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); @@ -391,7 +399,7 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); album.name = dto.name; album.shared = dto.shared; @@ -402,32 +410,33 @@ class SyncService { album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; album.activityEnabled = dto.activityEnabled; - if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.remoteThumbnailAssetId) - .findFirst(); + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); }); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -456,7 +465,7 @@ class SyncService { await upsertAssetsWithExif(updated); await _entityService.fillAlbumWithDatabaseEntities(album); - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); } else { _log.warning( "Failed to add album from server: assetCount ${album.remoteAssetCount} != " @@ -474,27 +483,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -509,7 +509,7 @@ class SyncService { ]) async { onDevice.sort((a, b) => a.id.compareTo(b.id)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); @@ -536,10 +536,9 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", @@ -570,13 +569,13 @@ class SyncService { await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await dbAlbum.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); @@ -597,15 +596,14 @@ class SyncService { "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag( - id: deviceAlbum.eTagKeyAssetCount, - assetCount: assetCountOnDevice, - ), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } @@ -625,23 +623,21 @@ class SyncService { dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await dbAlbum.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(dbAlbum); - dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst(); - await dbAlbum.thumbnail.save(); - await _db.eTags.put( + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ ETag( id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice, ), - ); + ]); }); _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } @@ -657,7 +653,8 @@ class SyncService { final int totalOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ?? + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? 0; if (totalOnDevice <= lastKnownTotal) { return false; @@ -675,16 +672,17 @@ class SyncService { _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await dbAlbum.assets.update(link: existingInDb + updated); - await _db.albums.put(dbAlbum); - await _db.eTags.put( - ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice), + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], ); }); _log.info("Fast synced local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe( "Failed to fast sync local album ${deviceAlbum.name} to DB", e, @@ -719,9 +717,9 @@ class SyncService { final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); _log.info("Added a new local album to DB: ${album.name}"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -732,7 +730,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -746,7 +744,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -758,24 +756,22 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); for (final Asset added in assets) { added.exifInfo?.id = added.id; } - await _db.exifInfos.putAll(exifInfos); + await _exifInfoRepository.updateAll(exifInfos); }); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -783,7 +779,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -827,19 +823,19 @@ class SyncService { return deviceAlbum.name != dbAlbum.name || !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount)) + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); }); return true; } catch (e) { diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 8520d89b43..c85487c7d0 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,17 +1,21 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; import '../../test_utils.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -20,6 +24,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -37,9 +42,13 @@ void main() { } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); @@ -53,7 +62,7 @@ void main() { late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -67,16 +76,43 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); s = SyncService( - db, hs, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), ); }); test('test inserting existing assets', () async { @@ -85,7 +121,6 @@ void main() { makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -93,7 +128,7 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { @@ -105,7 +140,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -113,7 +147,11 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { @@ -125,7 +163,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -133,7 +170,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -141,7 +183,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -150,7 +198,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -160,10 +207,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -171,6 +229,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -178,7 +238,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 6e220e85a2..c76a003eec 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,6 +18,10 @@ class MockUserRepository extends Mock implements IUserRepository {} class MockBackupRepository extends Mock implements IBackupRepository {} +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index b2c2ec4427..fb46dceed5 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -29,6 +29,13 @@ void main() { albumMediaRepository = MockAlbumMediaRepository(); albumApiRepository = MockAlbumApiRepository(); + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + sut = AlbumService( userService, syncService, @@ -144,7 +151,7 @@ void main() { ), ); when( - () => albumRepository.getById(AlbumStub.oneAsset.id), + () => albumRepository.get(AlbumStub.oneAsset.id), ).thenAnswer((_) async => AlbumStub.oneAsset); when( () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), From 47821cda35e0df2e5de408b55f590f6f96121c98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:16:04 -0400 Subject: [PATCH 096/599] chore(deps): bump docker/build-push-action from 6.7.0 to 6.9.0 (#13052) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.7.0 to 6.9.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6.7.0...v6.9.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 5292075cce..a86408eea8 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bf393bbcf6..8c7aeb020e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -173,7 +173,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -264,7 +264,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} From dfc2d5002b6c560150fd77e1d48b6b54d8315266 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 15:50:34 -0400 Subject: [PATCH 097/599] refactor(server): client events (#13062) --- server/src/interfaces/event.interface.ts | 46 ++++++------------- server/src/repositories/event.repository.ts | 8 ++-- server/src/services/job.service.ts | 10 ++-- .../src/services/notification.service.spec.ts | 12 ++--- server/src/services/notification.service.ts | 24 +++++----- server/src/services/version.service.ts | 8 ++-- .../repositories/event.repository.mock.ts | 4 +- 7 files changed, 48 insertions(+), 64 deletions(-) diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 02027d87e6..a125e47ada 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -62,36 +62,20 @@ export type EmitHandler = (...args: ArgsOf) => Promise = EventMap[T][0]; export type ArgsOf = EventMap[T]; -export enum ClientEvent { - UPLOAD_SUCCESS = 'on_upload_success', - USER_DELETE = 'on_user_delete', - ASSET_DELETE = 'on_asset_delete', - ASSET_TRASH = 'on_asset_trash', - ASSET_UPDATE = 'on_asset_update', - ASSET_HIDDEN = 'on_asset_hidden', - ASSET_RESTORE = 'on_asset_restore', - ASSET_STACK_UPDATE = 'on_asset_stack_update', - PERSON_THUMBNAIL = 'on_person_thumbnail', - SERVER_VERSION = 'on_server_version', - CONFIG_UPDATE = 'on_config_update', - NEW_RELEASE = 'on_new_release', - SESSION_DELETE = 'on_session_delete', -} - export interface ClientEventMap { - [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; - [ClientEvent.USER_DELETE]: string; - [ClientEvent.ASSET_DELETE]: string; - [ClientEvent.ASSET_TRASH]: string[]; - [ClientEvent.ASSET_UPDATE]: AssetResponseDto; - [ClientEvent.ASSET_HIDDEN]: string; - [ClientEvent.ASSET_RESTORE]: string[]; - [ClientEvent.ASSET_STACK_UPDATE]: string[]; - [ClientEvent.PERSON_THUMBNAIL]: string; - [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; - [ClientEvent.CONFIG_UPDATE]: Record; - [ClientEvent.NEW_RELEASE]: ReleaseNotification; - [ClientEvent.SESSION_DELETE]: string; + on_upload_success: [AssetResponseDto]; + on_user_delete: [string]; + on_asset_delete: [string]; + on_asset_trash: [string[]]; + on_asset_update: [AssetResponseDto]; + on_asset_hidden: [string]; + on_asset_restore: [string[]]; + on_asset_stack_update: string[]; + on_person_thumbnail: [string]; + on_server_version: [ServerVersionResponseDto]; + on_config_update: []; + on_new_release: [ReleaseNotification]; + on_session_delete: [string]; } export type EventItem = { @@ -107,11 +91,11 @@ export interface IEventRepository { /** * Send to connected clients for a specific user */ - clientSend(event: E, room: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; /** * Send to all connected clients */ - clientBroadcast(event: E, data: ClientEventMap[E]): void; + clientBroadcast(event: E, ...data: ClientEventMap[E]): void; /** * Send to all connected servers */ diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index a8b2fa67c3..90d8e7bf5d 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -106,12 +106,12 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect } } - clientSend(event: E, room: string, data: ClientEventMap[E]) { - this.server?.to(room).emit(event, data); + clientSend(event: T, room: string, ...data: ClientEventMap[T]) { + this.server?.to(room).emit(event, ...data); } - clientBroadcast(event: E, data: ClientEventMap[E]) { - this.server?.emit(event, data); + clientBroadcast(event: T, ...data: ClientEventMap[T]) { + this.server?.emit(event, ...data); } serverSend(event: T, ...args: ArgsOf): void { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 68da13a8e4..159efdf023 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -6,7 +6,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -279,7 +279,7 @@ export class JobService { if (item.data.source === 'sidecar-write') { const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); if (asset) { - this.eventRepository.clientSend(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); } } await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); @@ -302,7 +302,7 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.eventRepository.clientSend(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id); + this.eventRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); } break; } @@ -331,7 +331,7 @@ export class JobService { await this.jobRepository.queueAll(jobs); if (asset.isVisible) { - this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); } break; @@ -345,7 +345,7 @@ export class JobService { } case JobName.USER_DELETION: { - this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id); + this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 106f0be082..5fba38d1eb 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -104,7 +104,7 @@ describe(NotificationService.name, () => { it('should emit client and server events', () => { const update = { newConfig: defaults }; expect(sut.onConfigUpdate(update)).toBeUndefined(); - expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); }); }); @@ -236,28 +236,28 @@ describe(NotificationService.name, () => { describe('onStackCreate', () => { it('should send connected clients an event', () => { sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackUpdate', () => { it('should send connected clients an event', () => { sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStackDelete', () => { it('should send connected clients an event', () => { sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); describe('onStacksDelete', () => { it('should send connected clients an event', () => { sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); - expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 626e536c40..a3adfa4565 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -45,7 +45,7 @@ export class NotificationService { @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.clientBroadcast('on_config_update'); this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); } @@ -66,7 +66,7 @@ export class NotificationService { @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); + this.eventRepository.clientSend('on_asset_hidden', userId, assetId); } @OnEvent({ name: 'asset.show' }) @@ -76,42 +76,42 @@ export class NotificationService { @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); + this.eventRepository.clientSend('on_asset_trash', userId, [assetId]); } @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); + this.eventRepository.clientSend('on_asset_delete', userId, assetId); } @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); + this.eventRepository.clientSend('on_asset_trash', userId, assetIds); } @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); + this.eventRepository.clientSend('on_asset_restore', userId, assetIds); } @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + this.eventRepository.clientSend('on_asset_stack_update', userId); } @OnEvent({ name: 'user.signup' }) @@ -134,7 +134,7 @@ export class NotificationService { @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent - setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); } async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 0c7ae52cac..1d2785356e 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -7,7 +7,7 @@ import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -80,7 +80,7 @@ export class VersionService { if (semver.gt(releaseVersion, serverVersion)) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); - this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata)); + this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}`, error?.stack); @@ -92,10 +92,10 @@ export class VersionService { @OnEvent({ name: 'websocket.connect' }) async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { - this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); + this.eventRepository.clientSend('on_server_version', userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { - this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata)); + this.eventRepository.clientSend('on_new_release', userId, asNotification(metadata)); } } } diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index 78c62e95f2..6893b29f49 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -5,8 +5,8 @@ export const newEventRepositoryMock = (): Mocked => { return { on: vitest.fn() as any, emit: vitest.fn() as any, - clientSend: vitest.fn(), - clientBroadcast: vitest.fn(), + clientSend: vitest.fn() as any, + clientBroadcast: vitest.fn() as any, serverSend: vitest.fn(), }; }; From f63d251490de6d94dc47b853337b2a47801e9cc6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 16:04:24 -0400 Subject: [PATCH 098/599] refactor(server): user core (#13063) --- server/src/cores/user.core.ts | 51 ------------------- server/src/services/auth.service.ts | 38 +++++++------- server/src/services/user-admin.service.ts | 7 +-- server/src/utils/user.ts | 35 +++++++++++++ .../test/repositories/user.repository.mock.ts | 7 +-- 5 files changed, 59 insertions(+), 79 deletions(-) delete mode 100644 server/src/cores/user.core.ts create mode 100644 server/src/utils/user.ts diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts deleted file mode 100644 index 153463a9cc..0000000000 --- a/server/src/cores/user.core.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import sanitize from 'sanitize-filename'; -import { SALT_ROUNDS } from 'src/constants'; -import { UserEntity } from 'src/entities/user.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; - -let instance: UserCore | null; - -export class UserCore { - private constructor( - private cryptoRepository: ICryptoRepository, - private userRepository: IUserRepository, - ) {} - - static create(cryptoRepository: ICryptoRepository, userRepository: IUserRepository) { - if (!instance) { - instance = new UserCore(cryptoRepository, userRepository); - } - - return instance; - } - - static reset() { - instance = null; - } - - async createUser(dto: Partial & { email: string }): Promise { - const user = await this.userRepository.getByEmail(dto.email); - if (user) { - throw new BadRequestException('User exists'); - } - - if (!dto.isAdmin) { - const localAdmin = await this.userRepository.getAdmin(); - if (!localAdmin) { - throw new BadRequestException('The first registered account must the administrator.'); - } - } - - const payload: Partial = { ...dto }; - if (payload.password) { - payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); - } - if (payload.storageLabel) { - payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); - } - - return this.userRepository.create(payload); - } -} diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 6b1e4c512f..0917fc2198 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -14,7 +14,6 @@ import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto, ChangePasswordDto, @@ -42,6 +41,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository } from 'src/interfaces/user.interface'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; +import { createUser } from 'src/utils/user'; export interface LoginDetails { isSecure: boolean; @@ -72,7 +72,6 @@ export type ValidateRequest = { @Injectable() export class AuthService { private configCore: SystemConfigCore; - private userCore: UserCore; constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -86,7 +85,6 @@ export class AuthService { ) { this.logger.setContext(AuthService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - this.userCore = UserCore.create(cryptoRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30_000 }); } @@ -150,13 +148,16 @@ export class AuthService { throw new BadRequestException('The server already has an admin'); } - const admin = await this.userCore.createUser({ - isAdmin: true, - email: dto.email, - name: dto.name, - password: dto.password, - storageLabel: 'admin', - }); + const admin = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + isAdmin: true, + email: dto.email, + name: dto.name, + password: dto.password, + storageLabel: 'admin', + }, + ); return mapUserAdmin(admin); } @@ -271,13 +272,16 @@ export class AuthService { }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; - user = await this.userCore.createUser({ - name: userName, - email: profile.email, - oauthId: profile.sub, - quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, - storageLabel: storageLabel || null, - }); + user = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + name: userName, + email: profile.email, + oauthId: profile.sub, + quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, + storageLabel: storageLabel || null, + }, + ); } return this.createLoginResponse(user, loginDetails); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 6a5b6ea06e..75dff32f16 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -19,11 +18,10 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; +import { createUser } from 'src/utils/user'; @Injectable() export class UserAdminService { - private userCore: UserCore; - constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -32,7 +30,6 @@ export class UserAdminService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); this.logger.setContext(UserAdminService.name); } @@ -43,7 +40,7 @@ export class UserAdminService { async create(dto: UserAdminCreateDto): Promise { const { notify, ...rest } = dto; - const user = await this.userCore.createUser(rest); + const user = await createUser({ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, rest); await this.eventRepository.emit('user.signup', { notify: !!notify, diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts new file mode 100644 index 0000000000..c7029a1eca --- /dev/null +++ b/server/src/utils/user.ts @@ -0,0 +1,35 @@ +import { BadRequestException } from '@nestjs/common'; +import sanitize from 'sanitize-filename'; +import { SALT_ROUNDS } from 'src/constants'; +import { UserEntity } from 'src/entities/user.entity'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; + +type RepoDeps = { userRepo: IUserRepository; cryptoRepo: ICryptoRepository }; + +export const createUser = async ( + { userRepo, cryptoRepo }: RepoDeps, + dto: Partial & { email: string }, +): Promise => { + const user = await userRepo.getByEmail(dto.email); + if (user) { + throw new BadRequestException('User exists'); + } + + if (!dto.isAdmin) { + const localAdmin = await userRepo.getAdmin(); + if (!localAdmin) { + throw new BadRequestException('The first registered account must the administrator.'); + } + } + + const payload: Partial = { ...dto }; + if (payload.password) { + payload.password = await cryptoRepo.hashBcrypt(payload.password, SALT_ROUNDS); + } + if (payload.storageLabel) { + payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); + } + + return userRepo.create(payload); +}; diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 6071ae47fa..6362ab6a99 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,12 +1,7 @@ -import { UserCore } from 'src/cores/user.core'; import { IUserRepository } from 'src/interfaces/user.interface'; import { Mocked, vitest } from 'vitest'; -export const newUserRepositoryMock = (reset = true): Mocked => { - if (reset) { - UserCore.reset(); - } - +export const newUserRepositoryMock = (): Mocked => { return { get: vitest.fn(), getAdmin: vitest.fn(), From a019fb670e0c1edfea24cd6620e4eaecf54a0600 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Sep 2024 17:31:21 -0400 Subject: [PATCH 099/599] refactor(server): config service (#13066) * refactor(server): config service * fix: function renaming --------- Co-authored-by: Daniel Dietzler --- .../src/controllers/server-info.controller.ts | 2 +- server/src/controllers/server.controller.ts | 2 +- .../controllers/system-config.controller.ts | 4 +- server/src/cores/storage.core.ts | 12 +- server/src/cores/system-config.core.ts | 143 ------------------ server/src/services/asset.service.ts | 12 +- server/src/services/auth.service.ts | 20 ++- server/src/services/base.service.ts | 32 ++++ server/src/services/cli.service.ts | 26 ++-- server/src/services/duplicate.service.ts | 14 +- server/src/services/job.service.ts | 11 +- server/src/services/library.service.ts | 11 +- server/src/services/map.service.spec.ts | 10 +- server/src/services/map.service.ts | 12 +- server/src/services/media.service.ts | 18 +-- server/src/services/metadata.service.ts | 13 +- server/src/services/notification.service.ts | 20 ++- server/src/services/person.service.ts | 23 ++- server/src/services/search.service.ts | 12 +- server/src/services/server.service.spec.ts | 4 +- server/src/services/server.service.ts | 23 ++- server/src/services/shared-link.service.ts | 12 +- server/src/services/smart-info.service.ts | 16 +- .../src/services/storage-template.service.ts | 13 +- .../services/system-config.service.spec.ts | 26 ++-- server/src/services/system-config.service.ts | 31 ++-- server/src/services/user.service.ts | 14 +- server/src/services/version.service.ts | 14 +- server/src/utils/config.ts | 129 ++++++++++++++++ .../system-metadata.repository.mock.ts | 9 +- 30 files changed, 327 insertions(+), 361 deletions(-) delete mode 100644 server/src/cores/system-config.core.ts create mode 100644 server/src/services/base.service.ts create mode 100644 server/src/utils/config.ts diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 245bbbd347..36490b7119 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -66,7 +66,7 @@ export class ServerInfoController { @Get('config') @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('statistics') diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 75becfe341..8fcd93946e 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -58,7 +58,7 @@ export class ServerController { @Get('config') getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('statistics') diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 804c19500f..f59c8ad66c 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -13,7 +13,7 @@ export class SystemConfigController { @Get() @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('defaults') @@ -25,7 +25,7 @@ export class SystemConfigController { @Put() @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateConfig(dto); + return this.service.updateSystemConfig(dto); } @Get('storage-template-options') diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 8ce8f6b67a..d33d81410c 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; @@ -13,6 +12,7 @@ import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getAssetFiles } from 'src/utils/asset.util'; +import { getConfig } from 'src/utils/config'; export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); @@ -34,18 +34,15 @@ export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDE let instance: StorageCore | null; export class StorageCore { - private configCore; private constructor( private assetRepository: IAssetRepository, private cryptoRepository: ICryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, + private systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, - ) { - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } + ) {} static create( assetRepository: IAssetRepository, @@ -248,7 +245,8 @@ export class StorageCore { this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`); return false; } - const config = await this.configCore.getConfig({ withCache: true }); + const repos = { metadataRepo: this.systemMetadataRepository, logger: this.logger }; + const config = await getConfig(repos, { withCache: true }); if (assetInfo && config.storageTemplate.hashVerificationEnabled) { const { checksum } = assetInfo; const newChecksum = await this.cryptoRepository.hashFile(newPath); diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts deleted file mode 100644 index 816ab00446..0000000000 --- a/server/src/cores/system-config.core.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import AsyncLock from 'async-lock'; -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; -import { load as loadYaml } from 'js-yaml'; -import * as _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { getKeysDeep, unsetDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; - -export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; - -let instance: SystemConfigCore | null; - -@Injectable() -export class SystemConfigCore { - private readonly asyncLock = new AsyncLock(); - private config: SystemConfig | null = null; - private lastUpdated: number | null = null; - - private constructor( - private repository: ISystemMetadataRepository, - private logger: ILoggerRepository, - ) {} - - static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { - if (!instance) { - instance = new SystemConfigCore(repository, logger); - } - return instance; - } - - static reset() { - instance = null; - } - - invalidateCache() { - this.config = null; - this.lastUpdated = null; - } - - async getConfig({ withCache }: { withCache: boolean }): Promise { - if (!withCache || !this.config) { - const lastUpdated = this.lastUpdated; - await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { - if (lastUpdated === this.lastUpdated) { - this.config = await this.buildConfig(); - this.lastUpdated = Date.now(); - } - }); - } - - return this.config!; - } - - async updateConfig(newConfig: SystemConfig): Promise { - // get the difference between the new config and the default config - const partialConfig: DeepPartial = {}; - for (const property of getKeysDeep(defaults)) { - const newValue = _.get(newConfig, property); - const isEmpty = newValue === undefined || newValue === null || newValue === ''; - const defaultValue = _.get(defaults, property); - const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - - if (isEmpty || isEqual) { - continue; - } - - _.set(partialConfig, property, newValue); - } - - await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - - return this.getConfig({ withCache: false }); - } - - isUsingConfigFile() { - return !!process.env.IMMICH_CONFIG_FILE; - } - - private async buildConfig() { - // load partial - const partial = this.isUsingConfigFile() - ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - - // merge with defaults - const config = _.cloneDeep(defaults); - for (const property of getKeysDeep(partial)) { - _.set(config, property, _.get(partial, property)); - } - - // check for extra properties - const unknownKeys = _.cloneDeep(config); - for (const property of getKeysDeep(defaults)) { - unsetDeep(unknownKeys, property); - } - - if (!_.isEmpty(unknownKeys)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); - } - - // validate full config - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - if (this.isUsingConfigFile()) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } else { - this.logger.error('Validation error', errors); - } - } - - if (config.server.externalDomain.length > 0) { - config.server.externalDomain = new URL(config.server.externalDomain).origin; - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); - } - - return config; - } - - private async loadFromFile(filepath: string) { - try { - const file = await this.repository.readFile(filepath); - return loadYaml(file.toString()) as unknown; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - this.logger.error(error); - throw error; - } - } -} diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index aa88eaf957..171005ab74 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, @@ -38,13 +37,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; -export class AssetService { - private configCore: SystemConfigCore; - +export class AssetService extends BaseService { constructor( @Inject(IAccessRepository) private access: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -54,10 +52,10 @@ export class AssetService { @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IStackRepository) private stackRepository: IStackRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(AssetService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { @@ -214,7 +212,7 @@ export class AssetService { } async handleAssetDeletionCheck(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedBefore = DateTime.now() .minus(Duration.fromObject({ days: trashedDays })) diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 0917fc2198..72d251ce78 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -13,7 +13,6 @@ import { IncomingHttpHeaders } from 'node:http'; import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto, ChangePasswordDto, @@ -39,6 +38,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { createUser } from 'src/utils/user'; @@ -70,27 +70,25 @@ export type ValidateRequest = { }; @Injectable() -export class AuthService { - private configCore: SystemConfigCore; - +export class AuthService extends BaseService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(AuthService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); custom.setHttpOptionsDefaults({ timeout: 30_000 }); } async login(dto: LoginCredentialDto, details: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } @@ -212,7 +210,7 @@ export class AuthService { } async authorize(dto: OAuthConfigDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { throw new BadRequestException('OAuth is not enabled'); } @@ -228,7 +226,7 @@ export class AuthService { } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const profile = await this.getOAuthProfile(config, dto.url); const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -288,7 +286,7 @@ export class AuthService { } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.user.id) { @@ -310,7 +308,7 @@ export class AuthService { return LOGIN_URL; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { return LOGIN_URL; } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts new file mode 100644 index 0000000000..776858aa1a --- /dev/null +++ b/server/src/services/base.service.ts @@ -0,0 +1,32 @@ +import { Inject } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getConfig, updateConfig } from 'src/utils/config'; + +export class BaseService { + constructor( + @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) protected logger: ILoggerRepository, + ) {} + + getConfig(options: { withCache: boolean }) { + return getConfig( + { + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }, + options, + ); + } + + updateConfig(newConfig: SystemConfig) { + return updateConfig( + { + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }, + newConfig, + ); + } +} diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 1c25c306b6..c7ed510f5d 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,24 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class CliService { - private configCore: SystemConfigCore; - +export class CliService extends BaseService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(CliService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async listUsers(): Promise { @@ -42,26 +40,26 @@ export class CliService { } async disablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async disableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } } diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 35a1a7325b..00f738a613 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; @@ -17,24 +16,23 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class DuplicateService { - private configCore: SystemConfigCore; - +export class DuplicateService extends BaseService { constructor( @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(DuplicateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async getDuplicates(auth: AuthDto): Promise { @@ -44,7 +42,7 @@ export class DuplicateService { } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -65,7 +63,7 @@ export class DuplicateService { } async handleSearchDuplicates({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 159efdf023..8bcf5e5622 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; @@ -22,6 +21,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { @@ -44,8 +44,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { }; @Injectable() -export class JobService { - private configCore: SystemConfigCore; +export class JobService extends BaseService { private isMicroservices = false; constructor( @@ -55,10 +54,10 @@ export class JobService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IMetricRepository) private metricRepository: IMetricRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -198,7 +197,7 @@ export class JobService { } async init(jobHandlers: Record) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); for (const queueName of Object.values(QueueName)) { let concurrency = 1; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index b8b478531f..4b296570eb 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -3,7 +3,6 @@ import { R_OK } from 'node:constants'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, @@ -35,14 +34,14 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; @Injectable() -export class LibraryService { - private configCore: SystemConfigCore; +export class LibraryService extends BaseService { private watchLibraries = false; private watchLock = false; private watchers: Record Promise> = {}; @@ -55,15 +54,15 @@ export class LibraryService { @Inject(ILibraryRepository) private repository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { watch, scan } = config.library; diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index f8b73260af..e0127b73ef 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,34 +1,26 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MapService } from 'src/services/map.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(MapService.name, () => { let sut: MapService; let albumMock: Mocked; - let loggerMock: Mocked; let partnerMock: Mocked; let mapMock: Mocked; - let systemMetadataMock: Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); partnerMock = newPartnerRepositoryMock(); mapMock = newMapRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); - sut = new MapService(albumMock, loggerMock, partnerMock, mapMock, systemMetadataMock); + sut = new MapService(albumMock, partnerMock, mapMock); }); describe('getMapMarkers', () => { diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index 5836505e54..3b1ee58cf1 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,27 +1,17 @@ import { Inject } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getMyPartnerIds } from 'src/utils/asset.util'; export class MapService { - private configCore: SystemConfigCore; - constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(MapService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } + ) {} async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 71f432e040..1f72c373f4 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,8 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; - import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { @@ -43,14 +41,14 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class MediaService { - private configCore: SystemConfigCore; +export class MediaService extends BaseService { private storageCore: StorageCore; private maliOpenCL?: boolean; private devices?: string[]; @@ -64,10 +62,10 @@ export class MediaService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -161,7 +159,7 @@ export class MediaService { } async handleAssetMigration({ id }: IEntityJob): Promise { - const { image } = await this.configCore.getConfig({ withCache: true }); + const { image } = await this.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; @@ -235,7 +233,7 @@ export class MediaService { } private async generateImageThumbnails(asset: AssetEntity) { - const { image } = await this.configCore.getConfig({ withCache: true }); + const { image } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); @@ -269,7 +267,7 @@ export class MediaService { } private async generateVideoThumbnails(asset: AssetEntity) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { image, ffmpeg } = await this.getConfig({ withCache: true }); const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); @@ -339,7 +337,7 @@ export class MediaService { return JobStatus.FAILED; } - const { ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 9499a4bdd9..e39e22b92f 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -7,7 +7,6 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -39,6 +38,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -97,9 +97,8 @@ const validateRange = (value: number | undefined, min: number, max: number): Non }; @Injectable() -export class MetadataService { +export class MetadataService extends BaseService { private storageCore: StorageCore; - private configCore: SystemConfigCore; constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @@ -117,10 +116,10 @@ export class MetadataService { @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ITagRepository) private tagRepository: ITagRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -137,7 +136,7 @@ export class MetadataService { if (app !== 'microservices') { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } @@ -222,7 +221,7 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); + const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index a3adfa4565..cf6b89384d 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; @@ -20,27 +19,26 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService { - private configCore: SystemConfigCore; - +export class NotificationService extends BaseService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } @OnEvent({ name: 'config.update' }) @@ -149,7 +147,7 @@ export class NotificationService { throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { @@ -177,7 +175,7 @@ export class NotificationService { return JobStatus.SKIPPED; } - const { server } = await this.configCore.getConfig({ withCache: true }); + const { server } = await this.getConfig({ withCache: true }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { @@ -220,7 +218,7 @@ export class NotificationService { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { @@ -262,7 +260,7 @@ export class NotificationService { const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); for (const recipient of recipients) { const user = await this.userRepository.get(recipient.id, { withDeleted: false }); @@ -303,7 +301,7 @@ export class NotificationService { } async handleSendEmail(data: IEmailJob): Promise { - const { notifications } = await this.configCore.getConfig({ withCache: false }); + const { notifications } = await this.getConfig({ withCache: false }); if (!notifications.smtp.enabled) { return JobStatus.SKIPPED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index b009696b63..7cb6f42f15 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -55,6 +54,7 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; @@ -64,8 +64,7 @@ import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @Injectable() -export class PersonService { - private configCore: SystemConfigCore; +export class PersonService extends BaseService { private storageCore: StorageCore; constructor( @@ -75,15 +74,15 @@ export class PersonService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -102,7 +101,7 @@ export class PersonService { skip: (page - 1) * size, }; - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden, @@ -283,7 +282,7 @@ export class PersonService { } async handleQueueDetectFaces({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -314,7 +313,7 @@ export class PersonService { } async handleDetectFaces({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -375,7 +374,7 @@ export class PersonService { } async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -425,7 +424,7 @@ export class PersonService { } async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -519,7 +518,7 @@ export class PersonService { } async handleGeneratePersonThumbnail(data: IEntityJob): Promise { - const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c3cc5399c8..4227f35ec3 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -24,13 +23,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class SearchService { - private configCore: SystemConfigCore; - +export class SearchService extends BaseService { constructor( @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @@ -38,10 +36,10 @@ export class SearchService { @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { @@ -101,7 +99,7 @@ export class SearchService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { throw new BadRequestException('Smart search is not enabled'); } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 4e6a8972b0..e0cd41a27e 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -176,9 +176,9 @@ describe(ServerService.name, () => { }); }); - describe('getConfig', () => { + describe('getSystemConfig', () => { it('should respond the server configuration', async () => { - await expect(sut.getConfig()).resolves.toEqual({ + await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 708fe32db5..ed1533f667 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { @@ -22,24 +21,24 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; +import { isUsingConfigFile } from 'src/utils/config'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService { - private configCore: SystemConfigCore; - +export class ServerService extends BaseService { constructor( @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(ServerService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -91,7 +90,7 @@ export class ServerService { async getFeatures(): Promise { const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } = - await this.configCore.getConfig({ withCache: false }); + await this.getConfig({ withCache: false }); return { smartSearch: isSmartSearchEnabled(machineLearning), @@ -106,18 +105,18 @@ export class ServerService { oauth: oauth.enabled, oauthAutoLaunch: oauth.autoLaunch, passwordLogin: passwordLogin.enabled, - configFile: this.configCore.isUsingConfigFile(), + configFile: isUsingConfigFile(), email: notifications.smtp.enabled, }; } async getTheme() { - const { theme } = await this.configCore.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme; } - async getConfig(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); const isInitialized = await this.userRepository.hasAdmin(); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 54c7fdf25b..883270f808 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -20,22 +19,21 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() -export class SharedLinkService { - private configCore: SystemConfigCore; - +export class SharedLinkService extends BaseService { constructor( @Inject(IAccessRepository) private access: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SharedLinkService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } getAll(auth: AuthDto): Promise { @@ -195,7 +193,7 @@ export class SharedLinkService { return null; } - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index ef7865d25c..5db4236d58 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; @@ -18,14 +17,13 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService { - private configCore: SystemConfigCore; - +export class SmartInfoService extends BaseService { constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @@ -33,10 +31,10 @@ export class SmartInfoService { @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(ISearchRepository) private repository: ISearchRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -45,7 +43,7 @@ export class SmartInfoService { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } @@ -106,7 +104,7 @@ export class SmartInfoService { } async handleQueueEncodeClip({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -131,7 +129,7 @@ export class SmartInfoService { } async handleEncodeClip({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 33b08efc9b..6ce79be33f 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -13,7 +13,6 @@ import { supportedYearTokens, } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; @@ -29,6 +28,7 @@ import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; @@ -45,8 +45,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService { - private configCore: SystemConfigCore; +export class StorageTemplateService extends BaseService { private storageCore: StorageCore; private _template: { compiled: HandlebarsTemplateDelegate; @@ -71,10 +70,10 @@ export class StorageTemplateService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -117,7 +116,7 @@ export class StorageTemplateService { } async handleMigrationSingle({ id }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { return JobStatus.SKIPPED; @@ -147,7 +146,7 @@ export class StorageTemplateService { async handleMigration(): Promise { this.logger.log('Starting storage template migration'); - const { storageTemplate } = await this.configCore.getConfig({ withCache: true }); + const { storageTemplate } = await this.getConfig({ withCache: true }); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index ac517bb3ff..0e45e0b694 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -216,7 +216,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { systemMock.get.mockResolvedValue({}); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -227,7 +227,7 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { @@ -235,7 +235,7 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -245,7 +245,7 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(loggerMock.error).toHaveBeenCalledTimes(2); @@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); @@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -288,7 +288,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); @@ -304,7 +304,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { server: { externalDomain } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); }); } @@ -316,7 +316,7 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); }); @@ -335,10 +335,10 @@ describe(SystemConfigService.name, () => { systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); } else { - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } }); } @@ -382,7 +382,7 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); + await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(eventMock.emit).toHaveBeenCalledWith( 'config.update', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), @@ -392,7 +392,7 @@ describe(SystemConfigService.name, () => { it('should throw an error if a config file is in use', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 100ab6f47c..acf9f542ca 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -12,36 +12,35 @@ import { supportedWeekTokens, supportedYearTokens, } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; +import { clearConfigCache, isUsingConfigFile } from 'src/utils/config'; import { toPlainObject } from 'src/utils/object'; @Injectable() -export class SystemConfigService { - private core: SystemConfigCore; - +export class SystemConfigService extends BaseService { constructor( - @Inject(ISystemMetadataRepository) repository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(SystemConfigService.name); - this.core = SystemConfigCore.create(repository, this.logger); } @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { - const config = await this.core.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.eventRepository.emit('config.update', { newConfig: config }); } - async getConfig(): Promise { - const config = await this.core.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); return mapConfig(config); } @@ -57,7 +56,7 @@ export class SystemConfigService { this.logger.setLogLevel(level); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); // TODO only do this if the event is a socket.io event - this.core.invalidateCache(); + clearConfigCache(); } @OnEvent({ name: 'config.validate' }) @@ -67,12 +66,12 @@ export class SystemConfigService { } } - async updateConfig(dto: SystemConfigDto): Promise { - if (this.core.isUsingConfigFile()) { + async updateSystemConfig(dto: SystemConfigDto): Promise { + if (isUsingConfigFile()) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); } - const oldConfig = await this.core.getConfig({ withCache: false }); + const oldConfig = await this.getConfig({ withCache: false }); try { await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); @@ -81,7 +80,7 @@ export class SystemConfigService { throw new BadRequestException(error instanceof Error ? error.message : error); } - const newConfig = await this.core.updateConfig(dto); + const newConfig = await this.updateConfig(dto); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); @@ -104,7 +103,7 @@ export class SystemConfigService { } async getCustomCss(): Promise { - const { theme } = await this.core.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme.customCss; } diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index dca893aa82..f770d1d3b6 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -3,7 +3,6 @@ import { DateTime } from 'luxon'; import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; @@ -19,13 +18,12 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() -export class UserService { - private configCore: SystemConfigCore; - +export class UserService extends BaseService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @@ -33,10 +31,10 @@ export class UserService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async search(): Promise { @@ -189,7 +187,7 @@ export class UserService { async handleUserDeleteCheck(): Promise { const users = await this.userRepository.getDeletedUsers(); - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.jobRepository.queueAll( users.flatMap((user) => this.isReadyForDeletion(user, config.user.deleteDelay) @@ -201,7 +199,7 @@ export class UserService { } async handleUserDelete({ id, force }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return JobStatus.FAILED; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 1d2785356e..0479faaed0 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; @@ -12,6 +11,7 @@ import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { @@ -23,18 +23,16 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService { - private configCore: SystemConfigCore; - +export class VersionService extends BaseService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) logger: ILoggerRepository, ) { + super(systemMetadataRepository, logger); this.logger.setContext(VersionService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } @OnEvent({ name: 'app.bootstrap' }) @@ -58,7 +56,7 @@ export class VersionService { return JobStatus.SKIPPED; } - const { newVersionCheck } = await this.configCore.getConfig({ withCache: true }); + const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { return JobStatus.SKIPPED; } diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts new file mode 100644 index 0000000000..307db173ca --- /dev/null +++ b/server/src/utils/config.ts @@ -0,0 +1,129 @@ +import AsyncLock from 'async-lock'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { load as loadYaml } from 'js-yaml'; +import * as _ from 'lodash'; +import { SystemConfig, defaults } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; +import { SystemMetadataKey } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; + +export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; + +type RepoDeps = { + metadataRepo: ISystemMetadataRepository; + logger: ILoggerRepository; +}; + +const asyncLock = new AsyncLock(); +let config: SystemConfig | null = null; +let lastUpdated: number | null = null; + +export const clearConfigCache = () => { + config = null; + lastUpdated = null; +}; + +export const isUsingConfigFile = () => { + return !!process.env.IMMICH_CONFIG_FILE; +}; + +export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise => { + if (!withCache || !config) { + const timestamp = lastUpdated; + await asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (timestamp === lastUpdated) { + config = await buildConfig(repos); + lastUpdated = Date.now(); + } + }); + } + + return config!; +}; + +export const updateConfig = async (repos: RepoDeps, newConfig: SystemConfig): Promise => { + const { metadataRepo } = repos; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); + + if (isEmpty || isEqual) { + continue; + } + + _.set(partialConfig, property, newValue); + } + + await metadataRepo.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + + return getConfig(repos, { withCache: false }); +}; + +const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string) => { + try { + const file = await metadataRepo.readFile(filepath); + return loadYaml(file.toString()) as unknown; + } catch (error: Error | any) { + logger.error(`Unable to load configuration file: ${filepath}`); + logger.error(error); + throw error; + } +}; + +const buildConfig = async (repos: RepoDeps) => { + const { metadataRepo, logger } = repos; + + // load partial + const partial = isUsingConfigFile() + ? await loadFromFile(repos, process.env.IMMICH_CONFIG_FILE as string) + : await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG); + + // merge with defaults + const config = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(config, property, _.get(partial, property)); + } + + // check for extra properties + const unknownKeys = _.cloneDeep(config); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config + const errors = await validate(plainToInstance(SystemConfigDto, config)); + if (errors.length > 0) { + if (isUsingConfigFile()) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + logger.error('Validation error', errors); + } + } + + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; +}; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index e44301fb21..793dd4c1c0 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,12 +1,9 @@ -import { SystemConfigCore } from 'src/cores/system-config.core'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - +export const newSystemMetadataRepositoryMock = (): Mocked => { + clearConfigCache(); return { get: vitest.fn() as any, set: vitest.fn(), From fe33732958b555242acf5efe889ea856bbe51321 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 1 Oct 2024 08:18:13 +0700 Subject: [PATCH 100/599] chore(mobile): update photo_manager 3.5.0 (#13050) --- .../manifest.json | 1 + mobile/lib/repositories/file_media.repository.dart | 8 ++++++-- mobile/pubspec.lock | 4 ++-- mobile/pubspec.yaml | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json diff --git a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json new file mode 100644 index 0000000000..7391713b6f --- /dev/null +++ b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/mobile/lib/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart index e115868ba0..5612b378c3 100644 --- a/mobile/lib/repositories/file_media.repository.dart +++ b/mobile/lib/repositories/file_media.repository.dart @@ -16,8 +16,12 @@ class FileMediaRepository implements IFileMediaRepository { required String title, String? relativePath, }) async { - final entity = await PhotoManager.editor - .saveImage(data, title: title, relativePath: relativePath); + final entity = await PhotoManager.editor.saveImage( + data, + filename: title, + title: title, + relativePath: relativePath, + ); return AssetMediaRepository.toAsset(entity); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9dadbd1028..8127a2143f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1211,10 +1211,10 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "1e8bbe46a6858870e34c976aafd85378bed221ce31c1201961eba9ad3d94df9f" + sha256: "32a1ce1095aeaaa792a29f28c1f74613aa75109f21c2d4ab85be3ad9964230a4" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.5.0" photo_manager_image_provider: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 092b0bb75c..51c0005f5c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: sdk: flutter path_provider_ios: - photo_manager: ^3.2.3 + photo_manager: ^3.5.0 photo_manager_image_provider: ^2.1.1 flutter_hooks: ^0.20.4 hooks_riverpod: ^2.4.9 From d772cc6c6aacfe1cc71a82ef94d12bf6a71c28d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 08:23:15 +0700 Subject: [PATCH 101/599] chore(deps): update dependency lints to v5 (#13059) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/immich_lint/pubspec.lock | 6 +++--- mobile/immich_lint/pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock index 6b7a4c99c5..e81bad7da2 100644 --- a/mobile/immich_lint/pubspec.lock +++ b/mobile/immich_lint/pubspec.lock @@ -186,10 +186,10 @@ packages: dependency: "direct dev" description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" logging: dependency: transitive description: @@ -367,4 +367,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 78298f451e..9d1a3c26b3 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -11,4 +11,4 @@ dependencies: glob: ^2.1.2 dev_dependencies: - lints: ^4.0.0 + lints: ^5.0.0 From 14e6d23eebe1687eab0cf8d079a66ad7d8040656 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 01:26:39 +0000 Subject: [PATCH 102/599] chore(deps): update dependency @types/node to ^20.16.9 (#13069) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e508fe843f..2f7e9dda9e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -1337,9 +1337,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 522a8e593e..cee258bff5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index e7b463b0b2..f5ab18e4c8 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -1569,9 +1569,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 7c0025902d..c107732ab3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 72d7a3ec54..e977f56834 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 41bc3a3b16..17472327f7 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 646a26b1ee..450b210388 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", @@ -5389,9 +5389,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "dependencies": { "undici-types": "~6.19.2" } @@ -18701,9 +18701,9 @@ } }, "@types/node": { - "version": "20.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", - "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "version": "20.16.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", + "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", "requires": { "undici-types": "~6.19.2" } diff --git a/server/package.json b/server/package.json index d481610906..2e6238ad54 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.5", + "@types/node": "^20.16.9", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", From f0ad6627a5bba4a29e97d3247f47eeec95880f38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 21:54:28 -0400 Subject: [PATCH 103/599] fix(deps): update machine-learning (#13070) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 ++-- machine-learning/poetry.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index d982962fbc..3bfdf7d2e2 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu +FROM python:3.11-bookworm@sha256:3cdce69fd5663ca47c420ec4d4df8e3545519a4030372f7d2064fb1be2279844 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 5bb1726378..1f6a378eda 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" description = "multi backend asyncio cache" optional = false python-versions = "*" files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [package.extras] @@ -1237,13 +1237,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.25.0" +version = "0.25.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.25.0-py3-none-any.whl", hash = "sha256:e2f357b35d72d5012cfd127108c4e14abcd61ba4ebc90a5a374dc2456cb34e12"}, - {file = "huggingface_hub-0.25.0.tar.gz", hash = "sha256:fb5fbe6c12fcd99d187ec7db95db9110fb1a20505f23040a5449a717c1a0db4d"}, + {file = "huggingface_hub-0.25.1-py3-none-any.whl", hash = "sha256:a5158ded931b3188f54ea9028097312cb0acd50bffaaa2612014c3c526b44972"}, + {file = "huggingface_hub-0.25.1.tar.gz", hash = "sha256:9ff7cb327343211fbd06e2b149b8f362fd1e389454f3f14c6db75a4999ee20ff"}, ] [package.dependencies] @@ -1531,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.6" +version = "2.31.8" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.6-py3-none-any.whl", hash = "sha256:004c963c7a588dc15d57d710cdc6a262d85b57936d7fad3c38ac0657aa98fc3b"}, - {file = "locust-2.31.6.tar.gz", hash = "sha256:03b6da0491d6a0b905692d9ac128d9deec403f40dc605c481a90dbab5126318c"}, + {file = "locust-2.31.8-py3-none-any.whl", hash = "sha256:4194e3d4a0472f1206c51532ed527017f3da1a7d1037ca4b2f0735d5dcd2f78f"}, + {file = "locust-2.31.8.tar.gz", hash = "sha256:b240c0d3e1724317d9211e81e99fbe42a3469071ef4d34d2ae6a727776d56377"}, ] [package.dependencies] From 06048b6db92941ed819528f4b9d9fc47a0edfd9a Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 1 Oct 2024 04:08:25 +0200 Subject: [PATCH 104/599] feat: preload fonts (#13068) --- server/src/constants.ts | 1 - web/src/app.html | 2 ++ web/src/hooks.server.ts | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 web/src/hooks.server.ts diff --git a/server/src/constants.ts b/server/src/constants.ts index e0a4fe8cef..8115101ca0 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -23,7 +23,6 @@ export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); export const isDev = () => process.env.IMMICH_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; -export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283'; export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT; diff --git a/web/src/app.html b/web/src/app.html index ff6a8bf580..d76e52c859 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -13,6 +13,8 @@ + + %sveltekit.head% From 53940f7d423120872556f579e98b1749ee0a6fab Mon Sep 17 00:00:00 2001 From: John Stef Date: Tue, 19 Nov 2024 19:59:26 +0200 Subject: [PATCH 460/599] fix(mobile): make search page scrollable (#14228) Fixes #13657 --- mobile/lib/pages/search/search.page.dart | 44 +++++++++++++----------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 21d0e8f5c2..2fa7b5ecd1 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -661,29 +661,31 @@ class SearchEmptyContent extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - children: [ - SizedBox(height: 40), - Center( - child: Image.asset( - context.isDarkTheme - ? 'assets/polaroid-dark.png' - : 'assets/polaroid-light.png', - height: 125, + return NotificationListener( + onNotification: (_) => true, + child: ListView( + shrinkWrap: false, + children: [ + SizedBox(height: 40), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/polaroid-dark.png' + : 'assets/polaroid-light.png', + height: 125, + ), ), - ), - SizedBox(height: 16), - Center( - child: Text( - "Search for your photos and videos", - style: context.textTheme.labelLarge, + SizedBox(height: 16), + Center( + child: Text( + "Search for your photos and videos", + style: context.textTheme.labelLarge, + ), ), - ), - SizedBox(height: 32), - QuickLinkList(), - ], + SizedBox(height: 32), + QuickLinkList(), + ], + ), ); } } From 3a2e30e30ef5fe1c31fdee6a851670f3b91395c9 Mon Sep 17 00:00:00 2001 From: John Stef Date: Tue, 19 Nov 2024 20:09:29 +0200 Subject: [PATCH 461/599] fix(mobile): fixes on language change (#14089) * fix(mobile): make widgets rebuild on locale changes This will make the make the pages to instantly refresh the correct translated string, without the need to pop and push the settings page. * fix(mobile): set the default intl locale This is needed because across the app, you don't pass the context.locale to DateFormat, so by default it uses the system's locale. This will fix the issue without the need to refactor a lot of code. * feat(mobile): create localeProvider This provider can be used to refresh providers that provide UI elements and get cached. * fix(mobile): refresh asset providers on locale change This is necessary to update the locale on the already evaluated DateFormat. --------- Co-authored-by: Alex --- mobile/lib/main.dart | 6 ++++ mobile/lib/pages/common/settings.page.dart | 2 ++ mobile/lib/pages/library/library.page.dart | 1 + mobile/lib/providers/asset.provider.dart | 42 +++++++++++++--------- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index e9ca8ceb6a..7729972aa2 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -192,6 +192,12 @@ class ImmichAppState extends ConsumerState await ref.read(localNotificationService).setup(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + Intl.defaultLocale = context.locale.toLanguageTag(); + } + @override initState() { super.initState(); diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 117b0aedc0..a6ca239962 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -46,6 +46,7 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { + context.locale; return Scaffold( appBar: AppBar( centerTitle: false, @@ -129,6 +130,7 @@ class SettingsSubPage extends StatelessWidget { @override Widget build(BuildContext context) { + context.locale; return Scaffold( appBar: AppBar( centerTitle: false, diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 48d2c685ba..1161f068cf 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -23,6 +23,7 @@ class LibraryPage extends ConsumerWidget { const LibraryPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + context.locale; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 3855a00b76..9252de01bf 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -328,24 +329,31 @@ final assetWatcher = return db.assets.watchObject(asset.id, fireImmediately: true); }); -final assetsProvider = StreamProvider.family((ref, userId) { - if (userId == null) return const Stream.empty(); - final query = _commonFilterAndSort( - _assets(ref).where().ownerIdEqualToAnyChecksum(userId), - ); - return renderListGenerator(query, ref); -}); +final assetsProvider = StreamProvider.family( + (ref, userId) { + if (userId == null) return const Stream.empty(); + ref.watch(localeProvider); + final query = _commonFilterAndSort( + _assets(ref).where().ownerIdEqualToAnyChecksum(userId), + ); + return renderListGenerator(query, ref); + }, + dependencies: [localeProvider], +); -final multiUserAssetsProvider = - StreamProvider.family>((ref, userIds) { - if (userIds.isEmpty) return const Stream.empty(); - final query = _commonFilterAndSort( - _assets(ref) - .where() - .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)), - ); - return renderListGenerator(query, ref); -}); +final multiUserAssetsProvider = StreamProvider.family>( + (ref, userIds) { + if (userIds.isEmpty) return const Stream.empty(); + ref.watch(localeProvider); + final query = _commonFilterAndSort( + _assets(ref) + .where() + .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)), + ); + return renderListGenerator(query, ref); + }, + dependencies: [localeProvider], +); QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { final userId = ref.watch(currentUserProvider)?.isarId; From 69e50d0d27bcaefdb49de768005f3cfa92521bcd Mon Sep 17 00:00:00 2001 From: weathondev Date: Tue, 19 Nov 2024 19:19:50 +0100 Subject: [PATCH 462/599] feat: Added shortcuts, shift-multi select and missing menu options to Search (Galleryviewer) (#14213) feat: Added shortcuts, shift-multi select and missing menu options to GalleryViewer (Search, Share, Memories) Co-authored-by: Alex --- .../memory-page/memory-viewer.svelte | 21 +- .../individual-shared-viewer.svelte | 15 +- .../gallery-viewer/gallery-viewer.svelte | 217 ++++++++++++++++-- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 26 ++- 5 files changed, 238 insertions(+), 48 deletions(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index bca3b2024d..72723670e6 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -17,6 +17,8 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; + import { cancelMultiselect } from '$lib/utils/asset-utils'; + import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AppRoute, QueryParameter } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { type Viewport } from '$lib/stores/assets.store'; @@ -44,7 +46,6 @@ import { tweened } from 'svelte/motion'; import { derived as storeDerived } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { SvelteSet } from 'svelte/reactivity'; type MemoryIndex = { memoryIndex: number; @@ -64,13 +65,14 @@ let memoryWrapper: HTMLElement | undefined = $state(); let galleryInView = $state(false); let paused = $state(false); - let selectedAssets: SvelteSet = $state(new SvelteSet()); let current: MemoryAsset | undefined = $state(undefined); // let memories: MemoryAsset[] = []; let resetPromise = $state(Promise.resolve()); const { isViewing } = assetViewingStore; const viewport: Viewport = $state({ width: 0, height: 0 }); + const assetInteractionStore = createAssetInteractionStore(); + const { selectedAssets } = assetInteractionStore; const progressBarController = tweened(0, { duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), }); @@ -126,7 +128,7 @@ const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handleEscape = async () => goto(AppRoute.PHOTOS); - const handleSelectAll = () => (selectedAssets = new SvelteSet(current?.memory.assets || [])); + const handleSelectAll = () => assetInteractionStore.selectAssets(current?.memory.assets || []); const handleAction = async (action: 'reset' | 'pause' | 'play') => { switch (action) { case 'play': { @@ -207,13 +209,10 @@ current = loadFromParams($memories, target); }); - $effect(() => { - selectedAssets = galleryInView ? selectedAssets : new SvelteSet(); - }); - let isMultiSelectionMode = $derived(selectedAssets.size > 0); - let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite)); + let isMultiSelectionMode = $derived($selectedAssets.size > 0); + let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); $effect(() => { handlePromiseError(handleProgress($progressBarController)); @@ -238,7 +237,7 @@ {#if isMultiSelectionMode}
    - (selectedAssets = new SvelteSet())}> + cancelMultiselect(assetInteractionStore)}> @@ -485,7 +484,7 @@ onPrevious={handlePreviousAsset} assets={current.memory.assets} {viewport} - bind:selectedAssets + {assetInteractionStore} />
    diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 245a90f9f3..5d625cef9d 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -6,7 +6,7 @@ import { downloadArchive } from '$lib/utils/asset-utils'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { addSharedLinkAssets, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte'; @@ -14,6 +14,8 @@ import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; + import { cancelMultiselect } from '$lib/utils/asset-utils'; + import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import type { Viewport } from '$lib/stores/assets.store'; @@ -27,11 +29,12 @@ let { sharedLink = $bindable(), isOwned }: Props = $props(); const viewport: Viewport = $state({ width: 0, height: 0 }); - let selectedAssets: Set = $state(new Set()); + const assetInteractionStore = createAssetInteractionStore(); + const { selectedAssets } = assetInteractionStore; let innerWidth: number = $state(0); let assets = $derived(sharedLink.assets); - let isMultiSelectionMode = $derived(selectedAssets.size > 0); + let isMultiSelectionMode = $derived($selectedAssets.size > 0); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -70,7 +73,7 @@ }; const handleSelectAll = () => { - selectedAssets = new Set(assets); + assetInteractionStore.selectAssets(assets); }; @@ -78,7 +81,7 @@
    {#if isMultiSelectionMode} - (selectedAssets = new Set())}> + cancelMultiselect(assetInteractionStore)}> {#if sharedLink?.allowDownload} @@ -109,6 +112,6 @@ {/if}
    - +
    diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index b6bcdabdff..eda340e7e2 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -1,50 +1,63 @@ + + +{#if isShowDeleteConfirmation} + (isShowDeleteConfirmation = false)} + onConfirm={() => handlePromiseError(trashOrDelete(true))} + /> +{/if} + +{#if showShortcuts} + (showShortcuts = !showShortcuts)} /> +{/if} + {#if assets.length > 0}
    {#each assets as asset, i (i)} @@ -159,19 +338,21 @@ title={showAssetName ? asset.originalFileName : ''} > { if (isMultiSelectionMode) { - selectAssetHandler(asset); + handleSelectAssets(asset); return; } void viewAssetHandler(asset); }} - onSelect={(asset) => selectAssetHandler(asset)} + onSelect={(asset) => handleSelectAssets(asset)} + onMouseEvent={() => assetMouseEventHandler(asset)} onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} - selected={selectedAssets.has(asset)} {showArchiveIcon} + {asset} + selected={$selectedAssets.has(asset)} + selectionCandidate={$assetSelectionCandidates.has(asset)} thumbnailWidth={geometry.boxes[i].width} thumbnailHeight={geometry.boxes[i].height} /> diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 255a4373ca..728387753c 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; + import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; @@ -10,7 +11,6 @@ import type { Viewport } from '$lib/stores/assets.store'; import { foldersStore } from '$lib/stores/folders.store'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; - import { type AssetResponseDto } from '@immich/sdk'; import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -24,7 +24,6 @@ let { data }: Props = $props(); - let selectedAssets: Set = $state(new Set()); const viewport: Viewport = $state({ width: 0, height: 0 }); let pathSegments = $derived(data.path ? data.path.split('/') : []); @@ -32,6 +31,8 @@ let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); + const assetInteractionStore = createAssetInteractionStore(); + onMount(async () => { await foldersStore.fetchUniquePaths(); }); @@ -79,7 +80,7 @@
    = $state(new Set()); + + const assetInteractionStore = createAssetInteractionStore(); + const { selectedAssets } = assetInteractionStore; type SearchTerms = MetadataSearchDto & Pick; - let isMultiSelectionMode = $derived(selectedAssets.size > 0); - let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite)); + let isMultiSelectionMode = $derived($selectedAssets.size > 0); + let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY)); onMount(() => { @@ -81,7 +85,7 @@ } if (isMultiSelectionMode) { - selectedAssets = new Set(); + $selectedAssets = new Set(); return; } if (!$preventRaceConditionSearchBar) { @@ -125,7 +129,7 @@ searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); }; const handleSelectAll = () => { - selectedAssets = new Set(searchResultAssets); + assetInteractionStore.selectAssets(searchResultAssets); }; async function onSearchQueryUpdate() { @@ -216,8 +220,10 @@ const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); const onAddToAlbum = (assetIds: string[]) => { - const assetIdSet = new Set(assetIds); - searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); + if (terms.isNotInAlbum.toString() == 'true') { + const assetIdSet = new Set(assetIds); + searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); + } }; function getObjectKeys(obj: T): (keyof T)[] { @@ -230,7 +236,7 @@
    {#if isMultiSelectionMode}
    - (selectedAssets = new Set())}> + cancelMultiselect(assetInteractionStore)}> @@ -321,7 +327,7 @@ {#if searchResultAssets.length > 0} Date: Tue, 19 Nov 2024 15:36:55 -0600 Subject: [PATCH 463/599] fix: mobile album sync always triggered when opening the app (#14233) * fix: mobile album sync always triggered when opening the app * send lastModifiedAssetTimestamp when get individual album --- e2e/src/api/specs/album.e2e-spec.ts | 4 ++++ server/src/services/album.service.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 9e925c4021..5c101a0793 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -141,6 +141,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ isFavorite: false })], + lastModifiedAssetTimestamp: expect.any(String), }); }); @@ -297,6 +298,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], + lastModifiedAssetTimestamp: expect.any(String), }); }); @@ -327,6 +329,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], + lastModifiedAssetTimestamp: expect.any(String), }); }); @@ -340,6 +343,7 @@ describe('/albums', () => { ...user1Albums[0], assets: [], assetCount: 1, + lastModifiedAssetTimestamp: expect.any(String), }); }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 2cf83e9b99..e57e6b168c 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -74,7 +74,7 @@ export class AlbumService extends BaseService { startDate: albumMetadata[album.id].startDate, endDate: albumMetadata[album.id].endDate, assetCount: albumMetadata[album.id].assetCount, - lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, + lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; }), ); @@ -86,12 +86,14 @@ export class AlbumService extends BaseService { const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); + const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); return { ...mapAlbum(album, withAssets, auth), startDate: albumMetadataForIds.startDate, endDate: albumMetadataForIds.endDate, assetCount: albumMetadataForIds.assetCount, + lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, }; } From 34fae31fd4abc20067535cf7208401bc3a6dffe4 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:37:39 -0500 Subject: [PATCH 464/599] fix(server): remove unnecessary guc settings for vector search (#14237) remove unnecessary guc settings --- server/src/queries/search.repository.sql | 18 +++------------ server/src/repositories/search.repository.ts | 23 +++++++++++--------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index cd9a84b016..7de61ad03c 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -279,13 +279,7 @@ LIMIT -- SearchRepository.searchSmart START TRANSACTION SET - LOCAL vectors.enable_prefilter = on; - -SET - LOCAL vectors.search_mode = vbase; - -SET - LOCAL vectors.hnsw_ef_search = 100; + LOCAL vectors.hnsw_ef_search = 200; SELECT "asset"."id" AS "asset_id", "asset"."deviceAssetId" AS "asset_deviceAssetId", @@ -369,7 +363,7 @@ WHERE ORDER BY "search"."embedding" <= > $6 ASC LIMIT - 101 + 201 COMMIT -- SearchRepository.searchDuplicates @@ -404,12 +398,6 @@ WHERE -- SearchRepository.searchFaces START TRANSACTION -SET - LOCAL vectors.enable_prefilter = on; - -SET - LOCAL vectors.search_mode = vbase; - SET LOCAL vectors.hnsw_ef_search = 100; WITH @@ -436,7 +424,7 @@ WITH ORDER BY "search"."embedding" <= > $1 ASC LIMIT - 100 + 64 ) SELECT res.* diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 01b7773076..ba7d779e02 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -111,7 +111,7 @@ export class SearchRepository implements ISearchRepository { @GenerateSql({ params: [ - { page: 1, size: 100 }, + { page: 1, size: 200 }, { takenAfter: DummyValue.DATE, embedding: Array.from({ length: 512 }, Math.random), @@ -137,7 +137,10 @@ export class SearchRepository implements ISearchRepository { .orderBy('search.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); - await manager.query(this.getRuntimeConfig(pagination.size)); + const runtimeConfig = this.getRuntimeConfig(pagination.size); + if (runtimeConfig) { + await manager.query(runtimeConfig); + } results = await paginatedBuilder(builder, { mode: PaginationMode.LIMIT_OFFSET, skip: (pagination.page - 1) * pagination.size, @@ -196,7 +199,7 @@ export class SearchRepository implements ISearchRepository { { userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), - numResults: 100, + numResults: 10, maxDistance: 0.6, }, ], @@ -236,7 +239,10 @@ export class SearchRepository implements ISearchRepository { cte.addSelect(`faces.${col}`, col); } - await manager.query(this.getRuntimeConfig(numResults)); + const runtimeConfig = this.getRuntimeConfig(numResults); + if (runtimeConfig) { + await manager.query(runtimeConfig); + } results = await manager .createQueryBuilder() .select('res.*') @@ -421,17 +427,14 @@ export class SearchRepository implements ISearchRepository { return results.map(({ model }) => model).filter((item) => item !== ''); } - private getRuntimeConfig(numResults?: number): string { + private getRuntimeConfig(numResults?: number): string | undefined { if (this.vectorExtension === DatabaseExtension.VECTOR) { return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall } - let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;'; - if (numResults) { - runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`; + if (numResults && numResults !== 100) { + return `SET LOCAL vectors.hnsw_ef_search = ${Math.max(numResults, 100)};`; } - - return runtimeConfig; } } From a3712e40bd02fddd5496701b0f4cd49f9031a157 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 19 Nov 2024 22:40:56 +0100 Subject: [PATCH 465/599] fix: parse quota claim as number (#14178) --- server/src/services/auth.service.spec.ts | 8 ++++---- server/src/services/auth.service.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3701d3de56..d34e2673f5 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -53,7 +53,7 @@ const oauthUserWithDefaultQuota = { email, name: ' ', oauthId: sub, - quotaSizeInBytes: 1_073_741_824, + quotaSizeInBytes: '1073741824', storageLabel: null, }; @@ -567,7 +567,7 @@ describe('AuthService', () => { oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); + expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore an invalid storage quota', async () => { @@ -581,7 +581,7 @@ describe('AuthService', () => { oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); + expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should ignore a negative quota', async () => { @@ -595,7 +595,7 @@ describe('AuthService', () => { oauthResponse, ); - expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); + expect(userMock.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); }); it('should not set quota for 0 quota', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index b0094ae9ed..0d44fa0562 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; -import { isNumber, isString } from 'class-validator'; +import { isString } from 'class-validator'; import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; @@ -226,7 +226,7 @@ export class AuthService extends BaseService { const storageQuota = this.getClaim(profile, { key: storageQuotaClaim, default: defaultStorageQuota, - isValid: (value: unknown) => isNumber(value) && value >= 0, + isValid: (value: unknown) => Number(value) >= 0, }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; From ad510dd6fd96ddd5a934f7264cb59b37b91ed66a Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:57:14 -0500 Subject: [PATCH 466/599] feat(server): faster geodata import (#14241) * faster geodata import * revert logging change * unlogged tables * leave spare connection * use expression index instead of generated column * do btree indexing with others --- server/src/entities/geodata-places.entity.ts | 43 ++- .../natural-earth-countries.entity.ts | 20 +- ...943-NaturalEarthCountriesIdentityColumn.ts | 29 ++ server/src/repositories/map.repository.ts | 258 +++++++++--------- 4 files changed, 213 insertions(+), 137 deletions(-) create mode 100644 server/src/migrations/1732072134943-NaturalEarthCountriesIdentityColumn.ts diff --git a/server/src/entities/geodata-places.entity.ts b/server/src/entities/geodata-places.entity.ts index 966a50d5c9..eb32d1b99b 100644 --- a/server/src/entities/geodata-places.entity.ts +++ b/server/src/entities/geodata-places.entity.ts @@ -14,13 +14,42 @@ export class GeodataPlacesEntity { @Column({ type: 'float' }) latitude!: number; - // @Column({ - // generatedType: 'STORED', - // asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)', - // type: 'earth', - // }) - // earthCoord!: unknown; - + @Column({ type: 'char', length: 2 }) + countryCode!: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + admin1Code!: string; + + @Column({ type: 'varchar', length: 80, nullable: true }) + admin2Code!: string; + + @Column({ type: 'varchar', nullable: true }) + admin1Name!: string; + + @Column({ type: 'varchar', nullable: true }) + admin2Name!: string; + + @Column({ type: 'varchar', nullable: true }) + alternateNames!: string; + + @Column({ type: 'date' }) + modificationDate!: Date; +} + +@Entity('geodata_places_tmp', { synchronize: false }) +export class GeodataPlacesTempEntity { + @PrimaryColumn({ type: 'integer' }) + id!: number; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ type: 'float' }) + longitude!: number; + + @Column({ type: 'float' }) + latitude!: number; + @Column({ type: 'char', length: 2 }) countryCode!: string; diff --git a/server/src/entities/natural-earth-countries.entity.ts b/server/src/entities/natural-earth-countries.entity.ts index 19a12fa07b..0f97132045 100644 --- a/server/src/entities/natural-earth-countries.entity.ts +++ b/server/src/entities/natural-earth-countries.entity.ts @@ -2,7 +2,25 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity('naturalearth_countries', { synchronize: false }) export class NaturalEarthCountriesEntity { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' }) + id!: number; + + @Column({ type: 'varchar', length: 50 }) + admin!: string; + + @Column({ type: 'varchar', length: 3 }) + admin_a3!: string; + + @Column({ type: 'varchar', length: 50 }) + type!: string; + + @Column({ type: 'polygon' }) + coordinates!: string; +} + +@Entity('naturalearth_countries_tmp', { synchronize: false }) +export class NaturalEarthCountriesTempEntity { + @PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' }) id!: number; @Column({ type: 'varchar', length: 50 }) diff --git a/server/src/migrations/1732072134943-NaturalEarthCountriesIdentityColumn.ts b/server/src/migrations/1732072134943-NaturalEarthCountriesIdentityColumn.ts new file mode 100644 index 0000000000..3ebe8108cb --- /dev/null +++ b/server/src/migrations/1732072134943-NaturalEarthCountriesIdentityColumn.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NaturalEarthCountriesIdentityColumn1732072134943 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE naturalearth_countries ALTER id DROP DEFAULT`); + await queryRunner.query(`DROP SEQUENCE naturalearth_countries_id_seq`); + await queryRunner.query(`ALTER TABLE naturalearth_countries ALTER id ADD GENERATED ALWAYS AS IDENTITY`); + + // same as ll_to_earth, but with explicit schema to avoid weirdness and allow it to work in expression indices + await queryRunner.query(` + CREATE FUNCTION ll_to_earth_public(latitude double precision, longitude double precision) RETURNS public.earth PARALLEL SAFE IMMUTABLE STRICT LANGUAGE SQL AS $$ + SELECT public.cube(public.cube(public.cube(public.earth()*cos(radians(latitude))*cos(radians(longitude))),public.earth()*cos(radians(latitude))*sin(radians(longitude))),public.earth()*sin(radians(latitude)))::public.earth + $$`); + + await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "earthCoord"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE naturalearth_countries ALTER id DROP GENERATED`); + await queryRunner.query(`CREATE SEQUENCE naturalearth_countries_id_seq`); + await queryRunner.query( + `ALTER TABLE naturalearth_countries ALTER id SET DEFAULT nextval('naturalearth_countries_id_seq'::regclass)`, + ); + await queryRunner.query(`DROP FUNCTION ll_to_earth_public`); + await queryRunner.query( + `ALTER TABLE "geodata_places" ADD "earthCoord" earth GENERATED ALWAYS AS (ll_to_earth(latitude, longitude)) STORED`, + ); + } +} diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index f87ba6d0ac..348736a33d 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -1,14 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { getName } from 'i18n-iso-countries'; +import { randomUUID } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; import { citiesFile } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; -import { SystemMetadataKey } from 'src/enum'; +import { GeodataPlacesEntity, GeodataPlacesTempEntity } from 'src/entities/geodata-places.entity'; +import { + NaturalEarthCountriesEntity, + NaturalEarthCountriesTempEntity, +} from 'src/entities/natural-earth-countries.entity'; +import { LogLevel, SystemMetadataKey } from 'src/enum'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { @@ -20,7 +24,7 @@ import { } from 'src/interfaces/map.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { OptionalBetween } from 'src/utils/database'; -import { DataSource, In, IsNull, Not, QueryRunner, Repository } from 'typeorm'; +import { DataSource, In, IsNull, Not, Repository } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; @Injectable() @@ -49,8 +53,7 @@ export class MapRepository implements IMapRepository { return; } - await this.importGeodata(); - await this.importNaturalEarthCountries(); + await Promise.all([this.importGeodata(), this.importNaturalEarthCountries()]); await this.metadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { lastUpdate: geodataDate, @@ -116,13 +119,18 @@ export class MapRepository implements IMapRepository { const response = await this.geodataPlacesRepository .createQueryBuilder('geoplaces') - .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) - .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') + .where( + 'earth_box(ll_to_earth_public(:latitude, :longitude), 25000) @> ll_to_earth_public(latitude, longitude)', + point, + ) + .orderBy('earth_distance(ll_to_earth_public(:latitude, :longitude), ll_to_earth_public(latitude, longitude))') .limit(1) .getOne(); if (response) { - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); + if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { + this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); + } const { countryCode, name: city, admin1Name } = response; const country = getName(countryCode, 'en') ?? null; @@ -149,8 +157,9 @@ export class MapRepository implements IMapRepository { return { country: null, state: null, city: null }; } - this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); - + if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { + this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); + } const { admin_a3 } = ne_response; const country = getName(admin_a3, 'en') ?? null; const state = null; @@ -159,151 +168,119 @@ export class MapRepository implements IMapRepository { return { country, state, city }; } - private transformCoordinatesToPolygon(coordinates: number[][][]): string { - const pointsString = coordinates.map((point) => `(${point[0]},${point[1]})`).join(', '); - return `(${pointsString})`; - } - private async importNaturalEarthCountries() { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - const { resourcePaths } = this.configRepository.getEnv(); + const geoJSONData = JSON.parse(await readFile(resourcePaths.geodata.naturalEarthCountriesPath, 'utf8')); + if (geoJSONData.type !== 'FeatureCollection' || !Array.isArray(geoJSONData.features)) { + this.logger.fatal('Invalid GeoJSON FeatureCollection'); + return; + } - try { - await queryRunner.startTransaction(); - await queryRunner.manager.clear(NaturalEarthCountriesEntity); - - const fileContent = await readFile(resourcePaths.geodata.naturalEarthCountriesPath, 'utf8'); - const geoJSONData = JSON.parse(fileContent); - - if (geoJSONData.type !== 'FeatureCollection' || !Array.isArray(geoJSONData.features)) { - this.logger.fatal('Invalid GeoJSON FeatureCollection'); - return; - } - - for await (const feature of geoJSONData.features) { - for (const polygon of feature.geometry.coordinates) { - const featureRecord = new NaturalEarthCountriesEntity(); - featureRecord.admin = feature.properties.ADMIN; - featureRecord.admin_a3 = feature.properties.ADM0_A3; - featureRecord.type = feature.properties.TYPE; - - if (feature.geometry.type === 'MultiPolygon') { - featureRecord.coordinates = this.transformCoordinatesToPolygon(polygon[0]); - await queryRunner.manager.save(featureRecord); - } else if (feature.geometry.type === 'Polygon') { - featureRecord.coordinates = this.transformCoordinatesToPolygon(polygon); - await queryRunner.manager.save(featureRecord); - break; - } + await this.dataSource.query('DROP TABLE IF EXISTS naturalearth_countries_tmp'); + await this.dataSource.query( + 'CREATE UNLOGGED TABLE naturalearth_countries_tmp (LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES)', + ); + const entities: Omit[] = []; + for (const feature of geoJSONData.features) { + for (const entry of feature.geometry.coordinates) { + const coordinates: number[][][] = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry; + const featureRecord: Omit = { + admin: feature.properties.ADMIN, + admin_a3: feature.properties.ADM0_A3, + type: feature.properties.TYPE, + coordinates: `(${coordinates.map((point) => `(${point[0]},${point[1]})`).join(', ')})`, + }; + entities.push(featureRecord); + if (feature.geometry.type === 'Polygon') { + break; } } - - await queryRunner.commitTransaction(); - } catch (error) { - this.logger.fatal('Error importing natural earth country data', error); - await queryRunner.rollbackTransaction(); - throw error; - } finally { - await queryRunner.release(); } + await this.dataSource.manager.insert(NaturalEarthCountriesTempEntity, entities); + + await this.dataSource.query(`ALTER TABLE naturalearth_countries_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`); + + await this.dataSource.transaction(async (manager) => { + await manager.query('ALTER TABLE naturalearth_countries RENAME TO naturalearth_countries_old'); + await manager.query('ALTER TABLE naturalearth_countries_tmp RENAME TO naturalearth_countries'); + await manager.query('DROP TABLE naturalearth_countries_old'); + }); } private async importGeodata() { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - const { resourcePaths } = this.configRepository.getEnv(); - const admin1 = await this.loadAdmin(resourcePaths.geodata.admin1); - const admin2 = await this.loadAdmin(resourcePaths.geodata.admin2); + const [admin1, admin2] = await Promise.all([ + this.loadAdmin(resourcePaths.geodata.admin1), + this.loadAdmin(resourcePaths.geodata.admin2), + ]); - try { - await queryRunner.startTransaction(); + await this.dataSource.query('DROP TABLE IF EXISTS geodata_places_tmp'); + await this.dataSource.query( + 'CREATE UNLOGGED TABLE geodata_places_tmp (LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES)', + ); + await this.loadCities500(admin1, admin2); + await this.createGeodataIndices(); - await queryRunner.manager.clear(GeodataPlacesEntity); - await this.loadCities500(queryRunner, admin1, admin2); - - await queryRunner.commitTransaction(); - } catch (error) { - this.logger.fatal('Error importing geodata', error); - await queryRunner.rollbackTransaction(); - throw error; - } finally { - await queryRunner.release(); - } + await this.dataSource.transaction(async (manager) => { + await manager.query('ALTER TABLE geodata_places RENAME TO geodata_places_old'); + await manager.query('ALTER TABLE geodata_places_tmp RENAME TO geodata_places'); + await manager.query('DROP TABLE geodata_places_old'); + }); } - private async loadGeodataToTableFromFile( - queryRunner: QueryRunner, - lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity, - filePath: string, - options?: { entityFilter?: (linesplit: string[]) => boolean }, - ) { - const _entityFilter = options?.entityFilter ?? (() => true); - if (!existsSync(filePath)) { - this.logger.error(`Geodata file ${filePath} not found`); - throw new Error(`Geodata file ${filePath} not found`); + private async loadCities500(admin1Map: Map, admin2Map: Map) { + const { resourcePaths } = this.configRepository.getEnv(); + const cities500 = resourcePaths.geodata.cities500; + if (!existsSync(cities500)) { + throw new Error(`Geodata file ${cities500} not found`); } - const input = createReadStream(filePath); - let bufferGeodata: QueryDeepPartialEntity[] = []; + const input = createReadStream(cities500, { highWaterMark: 512 * 1024 * 1024 }); + let bufferGeodata: QueryDeepPartialEntity[] = []; const lineReader = readLine.createInterface({ input }); let count = 0; + let futures = []; for await (const line of lineReader) { const lineSplit = line.split('\t'); - if (!_entityFilter(lineSplit)) { + if (lineSplit[7] === 'PPLX' && lineSplit[8] !== 'AU') { continue; } - const geoData = lineToEntityMapper(lineSplit); + + const geoData = { + id: Number.parseInt(lineSplit[0]), + name: lineSplit[1], + alternateNames: lineSplit[3], + latitude: Number.parseFloat(lineSplit[4]), + longitude: Number.parseFloat(lineSplit[5]), + countryCode: lineSplit[8], + admin1Code: lineSplit[10], + admin2Code: lineSplit[11], + modificationDate: lineSplit[18], + admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`), + admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`), + }; bufferGeodata.push(geoData); - if (bufferGeodata.length >= 1000) { - await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); - count += bufferGeodata.length; - if (count % 10_000 === 0) { - this.logger.log(`${count} geodata records imported`); - } + if (bufferGeodata.length >= 5000) { + const curLength = bufferGeodata.length; + futures.push( + this.dataSource.manager.insert(GeodataPlacesTempEntity, bufferGeodata).then(() => { + count += curLength; + if (count % 10_000 === 0) { + this.logger.log(`${count} geodata records imported`); + } + }), + ); bufferGeodata = []; + // leave spare connection for other queries + if (futures.length >= 9) { + await Promise.all(futures); + futures = []; + } } } - await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); - } - private async loadCities500( - queryRunner: QueryRunner, - admin1Map: Map, - admin2Map: Map, - ) { - const { resourcePaths } = this.configRepository.getEnv(); - await this.loadGeodataToTableFromFile( - queryRunner, - (lineSplit: string[]) => - this.geodataPlacesRepository.create({ - id: Number.parseInt(lineSplit[0]), - name: lineSplit[1], - alternateNames: lineSplit[3], - latitude: Number.parseFloat(lineSplit[4]), - longitude: Number.parseFloat(lineSplit[5]), - countryCode: lineSplit[8], - admin1Code: lineSplit[10], - admin2Code: lineSplit[11], - modificationDate: lineSplit[18], - admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`), - admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`), - }), - resourcePaths.geodata.cities500, - { - entityFilter: (lineSplit) => { - if (lineSplit[7] === 'PPLX') { - // Exclude populated subsections of cities that are not in Australia. - // Australia has a lot of PPLX areas, so we include them. - return lineSplit[8] === 'AU'; - } - return true; - }, - }, - ); + await this.dataSource.manager.insert(GeodataPlacesTempEntity, bufferGeodata); } private async loadAdmin(filePath: string) { @@ -312,7 +289,7 @@ export class MapRepository implements IMapRepository { throw new Error(`Geodata file ${filePath} not found`); } - const input = createReadStream(filePath); + const input = createReadStream(filePath, { highWaterMark: 512 * 1024 * 1024 }); const lineReader = readLine.createInterface({ input }); const adminMap = new Map(); @@ -323,4 +300,27 @@ export class MapRepository implements IMapRepository { return adminMap; } + + private createGeodataIndices() { + return Promise.all([ + this.dataSource.query(`ALTER TABLE geodata_places_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`), + this.dataSource.query(` + CREATE INDEX IDX_geodata_gist_earthcoord_${randomUUID().replaceAll('-', '_')} + ON geodata_places_tmp + USING gist (ll_to_earth_public(latitude, longitude)) + WITH (fillfactor = 100)`), + this.dataSource.query(` + CREATE INDEX idx_geodata_places_name_${randomUUID().replaceAll('-', '_')} + ON geodata_places_tmp + USING gin (f_unaccent(name) gin_trgm_ops)`), + this.dataSource.query(` + CREATE INDEX idx_geodata_places_admin1_name_${randomUUID().replaceAll('-', '_')} + ON geodata_places_tmp + USING gin (f_unaccent("admin1Name") gin_trgm_ops)`), + this.dataSource.query(` + CREATE INDEX idx_geodata_places_admin2_name_${randomUUID().replaceAll('-', '_')} + ON geodata_places_tmp + USING gin (f_unaccent("admin2Name") gin_trgm_ops)`), + ]); + } } From cfba7f7701e59009137714ac7ce03f632c62bac5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:10:29 +0000 Subject: [PATCH 467/599] chore(deps): update terraform cloudflare to v4.46.0 (#14112) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 995f5d5b69..160c4f7ba5 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.45.0" - constraints = "4.45.0" + version = "4.46.0" + constraints = "4.46.0" hashes = [ - "h1:/CGpnYMkLRDmqn4iAsh/jg7ELZ6QExUw03VdjKZyK5M=", - "h1:82C/ryqwQvxhBINYOOyF5ZzPW/k4zJ/RYT13eCdPgEc=", - "h1:8Wu1D7ZwbLGdHakLRAzoAJ5VqZ8I14qzkPv1OGNfIlg=", - "h1:CVq0CAibeueOuiNk0UQtwZvMLMof33n1BgskFPOymrk=", - "h1:FSS5Kq+L+CX1zARy8PhaF8edBFNgsLtds4Uo8MwJiK8=", - "h1:L4qsorLII7f8xSFmv6JOoWfLWDunWQEpK964Bxk7mtM=", - "h1:StO3PV5PDskSCnhoHhWHOPxu6hbzJUQggfLgOSkvhwg=", - "h1:Tjo+Er9ets5YrTRIdP9LBmi4p89nL/W+A7r8a1MM9nI=", - "h1:XIwT+AWvks1LTytePM9zls+O8ItxoqCfPOgHwuH9ivQ=", - "h1:aOXn/zuM1+5GGy/SSRx8q4EYCSTFE9Tr0twHPIf5/KE=", - "h1:lb+YcuZ4guYd8zE51vgSnDsRAD9IV00Z15l1i1X52s8=", - "h1:pYwNXGjfXA2rUEmotGMLWgmavT9D2rdHnV3TpuIK3ko=", - "h1:q1qrnPq6KkljwBrugCwzb7f0SVP4Lzkfh+EOLARY9V8=", - "h1:v9sL4cZLTV5Gu2004DDyy7209gT0JmudBCAD0WCr/JE=", - "zh:00be2a6adc76615a368491c7a026098103b6286deb31e3cfb037365dd39f095f", - "zh:05bd072e6119f7a5abff05c6064001f745473119a956586cf77ae843cf55d666", - "zh:228bbe61345c4e8e0bc6b698b4b9652abff65662ee72ede2aecb4c3efb91b243", - "zh:2948aeefe71ba041c94082cf931ecc95510d93af0a61d0a287880f5b9d24b11a", - "zh:5dfc2c5e95843ca54957212ee3ecb7ff06f2cf60bfd6ca278b5249fd70ac18f5", - "zh:69922cb45559b0b0544b9c2d31ed2d0fac9121faa75bc2f523484785b45d8e2b", + "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=", + "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=", + "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=", + "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=", + "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=", + "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=", + "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=", + "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=", + "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=", + "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=", + "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=", + "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=", + "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=", + "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=", + "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343", + "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7", + "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463", + "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79", + "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418", + "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:9d83a0cbf72327286f7dbd63cd4af89059c648163fe6ed21b1df768e0518d445", - "zh:a8e1982945822c7d7aaa6ba8602c7247d1a3fad15d612f30eb323491a637bf8d", - "zh:c6d41ebd69ddb23e3dad49a0ebf1da5a9c7d8706a4f55d953115d371f407928b", - "zh:d03e5442b12846c2737f099d30cd23d9f85a0c6d65437ccb44819f9a6c4e1d7f", - "zh:d446f2e1186b35037aea03b0e27d8b032d2f069f194f84b3f0e2907b3a79a955", - "zh:e4d7549a4c856524e01f3dd4d69f57119ea205f7a0fa38dcfe154475b4ae9258", - "zh:e64b8915cb9686f85e77115bd674f2faf4f29880688067d7d0f1376566fdb3b0", - "zh:f046efdc55e6385cdd69baaa06a929bef9fe6809d373b0d2d6c7df8f8c23eddc", + "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10", + "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6", + "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd", + "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a", + "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa", + "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8", + "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf", + "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 85e095f195..f06c083bb0 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.45.0" + version = "4.46.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 995f5d5b69..160c4f7ba5 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.45.0" - constraints = "4.45.0" + version = "4.46.0" + constraints = "4.46.0" hashes = [ - "h1:/CGpnYMkLRDmqn4iAsh/jg7ELZ6QExUw03VdjKZyK5M=", - "h1:82C/ryqwQvxhBINYOOyF5ZzPW/k4zJ/RYT13eCdPgEc=", - "h1:8Wu1D7ZwbLGdHakLRAzoAJ5VqZ8I14qzkPv1OGNfIlg=", - "h1:CVq0CAibeueOuiNk0UQtwZvMLMof33n1BgskFPOymrk=", - "h1:FSS5Kq+L+CX1zARy8PhaF8edBFNgsLtds4Uo8MwJiK8=", - "h1:L4qsorLII7f8xSFmv6JOoWfLWDunWQEpK964Bxk7mtM=", - "h1:StO3PV5PDskSCnhoHhWHOPxu6hbzJUQggfLgOSkvhwg=", - "h1:Tjo+Er9ets5YrTRIdP9LBmi4p89nL/W+A7r8a1MM9nI=", - "h1:XIwT+AWvks1LTytePM9zls+O8ItxoqCfPOgHwuH9ivQ=", - "h1:aOXn/zuM1+5GGy/SSRx8q4EYCSTFE9Tr0twHPIf5/KE=", - "h1:lb+YcuZ4guYd8zE51vgSnDsRAD9IV00Z15l1i1X52s8=", - "h1:pYwNXGjfXA2rUEmotGMLWgmavT9D2rdHnV3TpuIK3ko=", - "h1:q1qrnPq6KkljwBrugCwzb7f0SVP4Lzkfh+EOLARY9V8=", - "h1:v9sL4cZLTV5Gu2004DDyy7209gT0JmudBCAD0WCr/JE=", - "zh:00be2a6adc76615a368491c7a026098103b6286deb31e3cfb037365dd39f095f", - "zh:05bd072e6119f7a5abff05c6064001f745473119a956586cf77ae843cf55d666", - "zh:228bbe61345c4e8e0bc6b698b4b9652abff65662ee72ede2aecb4c3efb91b243", - "zh:2948aeefe71ba041c94082cf931ecc95510d93af0a61d0a287880f5b9d24b11a", - "zh:5dfc2c5e95843ca54957212ee3ecb7ff06f2cf60bfd6ca278b5249fd70ac18f5", - "zh:69922cb45559b0b0544b9c2d31ed2d0fac9121faa75bc2f523484785b45d8e2b", + "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=", + "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=", + "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=", + "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=", + "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=", + "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=", + "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=", + "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=", + "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=", + "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=", + "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=", + "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=", + "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=", + "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=", + "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343", + "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7", + "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463", + "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79", + "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418", + "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:9d83a0cbf72327286f7dbd63cd4af89059c648163fe6ed21b1df768e0518d445", - "zh:a8e1982945822c7d7aaa6ba8602c7247d1a3fad15d612f30eb323491a637bf8d", - "zh:c6d41ebd69ddb23e3dad49a0ebf1da5a9c7d8706a4f55d953115d371f407928b", - "zh:d03e5442b12846c2737f099d30cd23d9f85a0c6d65437ccb44819f9a6c4e1d7f", - "zh:d446f2e1186b35037aea03b0e27d8b032d2f069f194f84b3f0e2907b3a79a955", - "zh:e4d7549a4c856524e01f3dd4d69f57119ea205f7a0fa38dcfe154475b4ae9258", - "zh:e64b8915cb9686f85e77115bd674f2faf4f29880688067d7d0f1376566fdb3b0", - "zh:f046efdc55e6385cdd69baaa06a929bef9fe6809d373b0d2d6c7df8f8c23eddc", + "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10", + "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6", + "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd", + "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a", + "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa", + "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8", + "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf", + "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 85e095f195..f06c083bb0 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.45.0" + version = "4.46.0" } } } From c17c17414946ea00bb17a16618c4355656f2453c Mon Sep 17 00:00:00 2001 From: Shivansh Saini Date: Thu, 21 Nov 2024 00:19:01 +0900 Subject: [PATCH 468/599] docs: backup only selected photos (#14225) Co-authored-by: Zack Pollard --- .../features/img/mobile-upload-open-photo.png | Bin 0 -> 388503 bytes .../img/mobile-upload-selected-photos.png | Bin 0 -> 2252444 bytes docs/docs/features/mobile-app.mdx | 27 ++++++++++++++++++ docs/docs/overview/quick-start.mdx | 1 + docs/docs/partials/_mobile-app-backup.md | 6 ++-- 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 docs/docs/features/img/mobile-upload-open-photo.png create mode 100644 docs/docs/features/img/mobile-upload-selected-photos.png diff --git a/docs/docs/features/img/mobile-upload-open-photo.png b/docs/docs/features/img/mobile-upload-open-photo.png new file mode 100644 index 0000000000000000000000000000000000000000..4e51826fd7bf5d1d41a495e62399dd064c06bea5 GIT binary patch literal 388503 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

    *(Tjq?zxRk-g7}(?Dg14$s^CWO> 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 z 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∾^y1_LjG~sh=>X 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⪻`;lL*&tXnap84p!bi7OmrGJ| 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

    kAlm&Wc(o*&2563b!&(xT$8 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

    6nhurRtChan`Fgt)FlqVJhKr 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 z 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 z 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

    yj`PG=2iM5WeBEbXnyNU3wLZS>Cx2CSRg+ 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 FzGvqK&)4fx$O-cz-oqQ{8|J8T}n1$Y7Uc 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∨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

    N8y|jKfuKa8fUHS|f-HhC|>P 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%0D{Ug$|$fyX0f#9?YONC|-0A1V!X)=YteuNWK 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

    {/if} -
    +
    {@render children?.()}
    diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f47d4a8c87..8d4fb809a5 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -84,6 +84,11 @@ export enum QueryParameter { PATH = 'path', } +export enum SessionStorageKey { + INFINITE_SCROLL_PAGE = 'infiniteScrollPage', + SCROLL_POSITION = 'scrollPosition', +} + export enum OpenSettingQueryParameterValue { OAUTH = 'oauth', JOB = 'job', diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 29079a48b8..239c6cc38a 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,5 +1,6 @@ - + {#snippet buttons()}
    diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bf3f3509c4..5c63d8e1a3 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,6 @@ -
    +
    {#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 13dac30691..0b51a7e240 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { focusTrap } from '$lib/actions/focus-trap'; + import { scrollMemory } from '$lib/actions/scroll-memory'; import Button from '$lib/components/elements/buttons/button.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; @@ -17,7 +18,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants'; + import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants'; import { locale } from '$lib/stores/preferences.store'; import { websocketEvents } from '$lib/stores/websocket'; import { handlePromiseError } from '$lib/utils'; @@ -50,6 +51,7 @@ let showSetBirthDateModal = $state(false); let showMergeModal = $state(false); let personName = $state(''); + let currentPage = $state(1); let nextPage = $state(data.people.hasNextPage ? 2 : null); let personMerge1 = $state(); let personMerge2 = $state(); @@ -68,6 +70,7 @@ handlePromiseError(searchPeopleElement.searchPeople(true, searchName)); } } + return websocketEvents.on('on_person_thumbnail', (personId: string) => { for (const person of people) { if (person.id === personId) { @@ -77,6 +80,36 @@ }); }); + const loadInitialScroll = () => + new Promise((resolve) => { + // Load up to previously loaded page when returning. + let newNextPage = sessionStorage.getItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + if (newNextPage && nextPage) { + let startingPage = nextPage, + pagesToLoad = Number.parseInt(newNextPage) - nextPage; + + if (pagesToLoad) { + handlePromiseError( + Promise.all( + Array.from({ length: pagesToLoad }).map((_, i) => { + return getAllPeople({ withHidden: true, page: startingPage + i }); + }), + ).then((pages) => { + for (const page of pages) { + people = people.concat(page.people); + } + currentPage = startingPage + pagesToLoad - 1; + nextPage = pages.at(-1)?.hasNextPage ? startingPage + pagesToLoad : null; + resolve(); // wait until extra pages are loaded + }), + ); + } else { + resolve(); + } + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + } + }); + const loadNextPage = async () => { if (!nextPage) { return; @@ -85,6 +118,9 @@ try { const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage }); people = people.concat(newPeople); + if (nextPage !== null) { + currentPage = nextPage; + } nextPage = hasNextPage ? nextPage + 1 : null; } catch (error) { handleError(error, $t('errors.failed_to_load_people')); @@ -323,6 +359,23 @@ { + if (currentPage) { + sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString()); + } + }, + beforeClear: () => { + sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE); + }, + beforeLoad: loadInitialScroll, + }, + ], + ]} > {#snippet buttons()} {#if people.length > 0} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index d9b7c6a08f..502ce715bd 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,7 @@ - -{#await handleAlbumCount()} - -{:then data} -
    -

    {$t('albums_count', { values: { count: data[albumType] } })}

    -
    -{/await} diff --git a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte deleted file mode 100644 index 5e4589be18..0000000000 --- a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -{#await getAssetStatistics(assetStats)} - -{:then data} -
    -

    {$t('videos_count', { values: { count: data.videos } })}

    -

    {$t('photos_count', { values: { count: data.images } })}

    -
    -{/await} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index d3fd94ae08..13f08533c5 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -1,10 +1,7 @@ + +{#snippet row(viewName: string, imageCount: number, videoCount: number, totalCount: number)} + {viewName} + {imageCount} + {videoCount} + {totalCount} +{/snippet} + +
    +

    {$t('photos_and_videos')}

    + + + + + + + + + + + + {@render row($t('timeline'), timelineStats.images, timelineStats.videos, timelineStats.total)} + + + + {@render row($t('favorites'), favoriteStats.images, favoriteStats.videos, favoriteStats.total)} + + + + {@render row($t('archive'), archiveStats.images, archiveStats.videos, archiveStats.total)} + + + + {@render row($t('trash'), trashStats.images, trashStats.videos, trashStats.total)} + + +
    {$t('view').toLocaleString()}{$t('photos').toLocaleString()}{$t('videos').toLocaleString()}{$t('total').toLocaleString()}
    + +
    +

    {$t('albums')}

    +
    + + + + + + + + + + + + + +
    {$t('owned')}{$t('shared')}
    {albumStats.owned.toLocaleString()}{albumStats.shared.toLocaleString()}
    +
    From 25488b3138403857350b9ac4e60cd6077ce04cab Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:59:56 -0500 Subject: [PATCH 495/599] chore(deployment): cleanup database container args, move to using internal container ENV vars (#14352) * cleanup docker, normalize variable use * newline * semicolons --- docker/docker-compose.dev.yml | 31 ++++++++++++++----------------- docker/docker-compose.prod.yml | 31 ++++++++++++++----------------- docker/docker-compose.yml | 31 ++++++++++++++----------------- 3 files changed, 42 insertions(+), 51 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 0b14ea7d6a..5da5bd3f91 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -125,26 +125,23 @@ services: ports: - 5432:5432 healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # immich-prometheus: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 96e324f0d9..8d80003ee4 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -67,26 +67,23 @@ services: ports: - 5432:5432 healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on restart: always # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 86ec637cbb..4b8453ce58 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -65,26 +65,23 @@ services: # Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file - ${DB_DATA_LOCATION}:/var/lib/postgresql/data healthcheck: - test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + test: >- + pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1; + Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align + --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; + echo "checksum failure count is $$Chksum"; + [ "$$Chksum" = '0' ] || exit 1 interval: 5m start_interval: 30s start_period: 5m - command: - [ - 'postgres', - '-c', - 'shared_preload_libraries=vectors.so', - '-c', - 'search_path="$$user", public, vectors', - '-c', - 'logging_collector=on', - '-c', - 'max_wal_size=2GB', - '-c', - 'shared_buffers=512MB', - '-c', - 'wal_compression=on', - ] + command: >- + postgres + -c shared_preload_libraries=vectors.so + -c 'search_path="$$user", public, vectors' + -c logging_collector=on + -c max_wal_size=2GB + -c shared_buffers=512MB + -c wal_compression=on restart: always volumes: From b6ec79cbddc3bdb9e638e4a59e2946ad0abe70ec Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:02:48 +0100 Subject: [PATCH 496/599] fix(web): timeline issues on person page (#14366) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 502ce715bd..48e194dda4 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -63,14 +63,17 @@ data: PageData; } - let { data = $bindable() }: Props = $props(); + let { data }: Props = $props(); let numberOfAssets = $state(data.statistics.assets); let { isViewing: showAssetViewer } = assetViewingStore; - let assetStore = new AssetStore({ - isArchived: false, - personId: data.person.id, + const assetStoreOptions = { isArchived: false, personId: data.person.id }; + const assetStore = new AssetStore(assetStoreOptions); + + $effect(() => { + assetStoreOptions.personId = data.person.id; + handlePromiseError(assetStore.updateOptions(assetStoreOptions)); }); const assetInteractionStore = createAssetInteractionStore(); @@ -329,7 +332,6 @@ $effect(() => { if (person) { handlePromiseError(updateAssetCount()); - handlePromiseError(assetStore.updateOptions({ personId: person.id })); } }); From 5417e34fb6dc90455ab9df68b497e7f3aa46b38b Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Tue, 26 Nov 2024 10:51:01 -0500 Subject: [PATCH 497/599] feat(server): Add publicUsers toggle for user search (#14330) * feat(server): Add publicUsers toggle for user search * tests * docs: add check:typescript for web PR checklist * return auth.user when publicUsers is false - app testing --------- Co-authored-by: Alex --- docs/docs/developer/pr-checklist.md | 1 + e2e/src/api/specs/server.e2e-spec.ts | 1 + i18n/en.json | 2 ++ .../openapi/lib/model/server_config_dto.dart | 10 ++++++- .../lib/model/system_config_server_dto.dart | 14 +++++++--- open-api/immich-openapi-specs.json | 10 ++++++- open-api/typescript-sdk/src/fetch-client.ts | 2 ++ server/src/config.ts | 2 ++ server/src/controllers/user.controller.ts | 4 +-- server/src/dtos/server.dto.ts | 1 + server/src/dtos/system-config.dto.ts | 3 +++ server/src/services/server.service.spec.ts | 1 + server/src/services/server.service.ts | 1 + .../services/system-config.service.spec.ts | 1 + server/src/services/user.service.spec.ts | 27 +++++++++++++++++-- server/src/services/user.service.ts | 10 +++++-- server/test/fixtures/system-config.stub.ts | 5 ++++ .../settings/server/server-settings.svelte | 8 ++++++ web/src/lib/stores/server-config.store.ts | 1 + 19 files changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index 6015694976..58581e669a 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -11,6 +11,7 @@ When contributing code through a pull request, please check the following: - [ ] `npm run lint` (linting via ESLint) - [ ] `npm run format` (formatting via Prettier) - [ ] `npm run check:svelte` (Type checking via SvelteKit) +- [ ] `npm run check:typescript` (check typescript) - [ ] `npm test` (unit tests) ## Documentation diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index 4bff4b3dea..c89280f579 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -133,6 +133,7 @@ describe('/server', () => { userDeleteDelay: 7, isInitialized: true, externalDomain: '', + publicUsers: true, isOnboarded: false, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', diff --git a/i18n/en.json b/i18n/en.json index 91bbb6cda2..9224597feb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -224,6 +224,8 @@ "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", + "server_public_users": "Public Users", + "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.", "server_settings": "Server Settings", "server_settings_description": "Manage server settings", "server_welcome_message": "Welcome message", diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index bd5c2405e2..01c82af4d9 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -20,6 +20,7 @@ class ServerConfigDto { required this.mapDarkStyleUrl, required this.mapLightStyleUrl, required this.oauthButtonText, + required this.publicUsers, required this.trashDays, required this.userDeleteDelay, }); @@ -38,6 +39,8 @@ class ServerConfigDto { String oauthButtonText; + bool publicUsers; + int trashDays; int userDeleteDelay; @@ -51,6 +54,7 @@ class ServerConfigDto { other.mapDarkStyleUrl == mapDarkStyleUrl && other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && + other.publicUsers == publicUsers && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -64,11 +68,12 @@ class ServerConfigDto { (mapDarkStyleUrl.hashCode) + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + + (publicUsers.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -79,6 +84,7 @@ class ServerConfigDto { json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; + json[r'publicUsers'] = this.publicUsers; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; return json; @@ -100,6 +106,7 @@ class ServerConfigDto { mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, + publicUsers: mapValueOfType(json, r'publicUsers')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, ); @@ -156,6 +163,7 @@ class ServerConfigDto { 'mapDarkStyleUrl', 'mapLightStyleUrl', 'oauthButtonText', + 'publicUsers', 'trashDays', 'userDeleteDelay', }; diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index b1b92c9515..8099292dd0 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -15,30 +15,36 @@ class SystemConfigServerDto { SystemConfigServerDto({ required this.externalDomain, required this.loginPageMessage, + required this.publicUsers, }); String externalDomain; String loginPageMessage; + bool publicUsers; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigServerDto && other.externalDomain == externalDomain && - other.loginPageMessage == loginPageMessage; + other.loginPageMessage == loginPageMessage && + other.publicUsers == publicUsers; @override int get hashCode => // ignore: unnecessary_parenthesis (externalDomain.hashCode) + - (loginPageMessage.hashCode); + (loginPageMessage.hashCode) + + (publicUsers.hashCode); @override - String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage]'; + String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage, publicUsers=$publicUsers]'; Map toJson() { final json = {}; json[r'externalDomain'] = this.externalDomain; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'publicUsers'] = this.publicUsers; return json; } @@ -53,6 +59,7 @@ class SystemConfigServerDto { return SystemConfigServerDto( externalDomain: mapValueOfType(json, r'externalDomain')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + publicUsers: mapValueOfType(json, r'publicUsers')!, ); } return null; @@ -102,6 +109,7 @@ class SystemConfigServerDto { static const requiredKeys = { 'externalDomain', 'loginPageMessage', + 'publicUsers', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4674232139..20ebe607a4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10825,6 +10825,9 @@ "oauthButtonText": { "type": "string" }, + "publicUsers": { + "type": "boolean" + }, "trashDays": { "type": "integer" }, @@ -10840,6 +10843,7 @@ "mapDarkStyleUrl", "mapLightStyleUrl", "oauthButtonText", + "publicUsers", "trashDays", "userDeleteDelay" ], @@ -12014,11 +12018,15 @@ }, "loginPageMessage": { "type": "string" + }, + "publicUsers": { + "type": "boolean" } }, "required": [ "externalDomain", - "loginPageMessage" + "loginPageMessage", + "publicUsers" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ddec0f2421..9b79816091 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -928,6 +928,7 @@ export type ServerConfigDto = { mapDarkStyleUrl: string; mapLightStyleUrl: string; oauthButtonText: string; + publicUsers: boolean; trashDays: number; userDeleteDelay: number; }; @@ -1222,6 +1223,7 @@ export type SystemConfigReverseGeocodingDto = { export type SystemConfigServerDto = { externalDomain: string; loginPageMessage: string; + publicUsers: boolean; }; export type SystemConfigStorageTemplateDto = { enabled: boolean; diff --git a/server/src/config.ts b/server/src/config.ts index 2b74f00e7a..f5e78ab01b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -149,6 +149,7 @@ export interface SystemConfig { server: { externalDomain: string; loginPageMessage: string; + publicUsers: boolean; }; user: { deleteDelay: number; @@ -296,6 +297,7 @@ export const defaults = Object.freeze({ server: { externalDomain: '', loginPageMessage: '', + publicUsers: true, }, notifications: { smtp: { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 10076098d6..15bb1913db 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -39,8 +39,8 @@ export class UserController { @Get() @Authenticated() - searchUsers(): Promise { - return this.service.search(); + searchUsers(@Auth() auth: AuthDto): Promise { + return this.service.search(auth); } @Get('me') diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index cbabfa7aed..e1f94dbaa5 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -144,6 +144,7 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + publicUsers!: boolean; mapDarkStyleUrl!: string; mapLightStyleUrl!: string; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 7a2e0632b4..8d79fecb22 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -404,6 +404,9 @@ class SystemConfigServerDto { @IsString() loginPageMessage!: string; + + @IsBoolean() + publicUsers!: boolean; } class SystemConfigSmtpTransportDto { diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 475d1d6193..3f7fafcebf 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -169,6 +169,7 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + publicUsers: true, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 7df322a84e..e9dd908a7c 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -110,6 +110,7 @@ export class ServerService extends BaseService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index f9ee42eb03..4d5a29e8a8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -133,6 +133,7 @@ const updatedConfig = Object.freeze({ server: { externalDomain: '', loginPageMessage: '', + publicUsers: true, }, storageTemplate: { enabled: false, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 767d8d8954..08b663046b 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -38,9 +38,9 @@ describe(UserService.name, () => { }); describe('getAll', () => { - it('should get all users', async () => { + it('admin should get all users', async () => { userMock.getList.mockResolvedValue([userStub.admin]); - await expect(sut.search()).resolves.toEqual([ + await expect(sut.search(authStub.admin)).resolves.toEqual([ expect.objectContaining({ id: authStub.admin.user.id, email: authStub.admin.user.email, @@ -48,6 +48,29 @@ describe(UserService.name, () => { ]); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); }); + + it('non-admin should get all users when publicUsers enabled', async () => { + userMock.getList.mockResolvedValue([userStub.user1]); + await expect(sut.search(authStub.user1)).resolves.toEqual([ + expect.objectContaining({ + id: authStub.user1.user.id, + email: authStub.user1.user.email, + }), + ]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false }); + }); + + it('non-admin user should only receive itself when publicUsers is disabled', async () => { + userMock.getList.mockResolvedValue([userStub.user1]); + systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled); + await expect(sut.search(authStub.user1)).resolves.toEqual([ + expect.objectContaining({ + id: authStub.user1.user.id, + email: authStub.user1.user.email, + }), + ]); + expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false }); + }); }); describe('get', () => { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 926482fb9c..f4ae42b5ed 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -19,8 +19,14 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti @Injectable() export class UserService extends BaseService { - async search(): Promise { - const users = await this.userRepository.getList({ withDeleted: false }); + async search(auth: AuthDto): Promise { + const config = await this.getConfig({ withCache: false }); + + let users: UserEntity[] = [auth.user]; + if (auth.user.isAdmin || config.server.publicUsers) { + users = await this.userRepository.getList({ withDeleted: false }); + } + return users.map((user) => mapUser(user)); } diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 10a0de77b0..ed8cc8694a 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -117,4 +117,9 @@ export const systemConfigStub = { }, }, }, + publicUsersDisabled: { + server: { + publicUsers: false, + }, + }, } satisfies Record>; diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte index 14d5624c5f..b9134d1e50 100644 --- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte +++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte @@ -5,6 +5,7 @@ import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { t } from 'svelte-i18n'; import { SettingInputFieldType } from '$lib/constants'; @@ -44,6 +45,13 @@ isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage} /> + +
    onReset({ ...options, configKeys: ['server'] })} diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 358765fe0b..254db71946 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -34,6 +34,7 @@ export const serverConfig = writable({ externalDomain: '', mapDarkStyleUrl: '', mapLightStyleUrl: '', + publicUsers: true, }); export const retrieveServerConfig = async () => { From 21f14be9490440d045fc12a8e57a3cb8da1da516 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Nov 2024 12:43:44 -0600 Subject: [PATCH 498/599] chore(mobile): refactor authentication (#14322) --- mobile/analysis_options.yaml | 2 +- mobile/lib/interfaces/auth.interface.dart | 5 + mobile/lib/interfaces/auth_api.interface.dart | 9 + .../auth_state.model.dart} | 19 +- .../lib/models/auth/login_response.model.dart | 30 +++ .../lib/pages/common/album_options.page.dart | 4 +- .../lib/pages/common/album_viewer.page.dart | 4 +- .../lib/pages/common/splash_screen.page.dart | 13 +- .../providers/app_life_cycle.provider.dart | 6 +- mobile/lib/providers/auth.provider.dart | 164 ++++++++++++ .../providers/authentication.provider.dart | 245 ------------------ .../lib/providers/backup/backup.provider.dart | 8 +- mobile/lib/providers/websocket.provider.dart | 4 +- mobile/lib/repositories/auth.repository.dart | 28 ++ .../lib/repositories/auth_api.repository.dart | 56 ++++ .../lib/repositories/database.repository.dart | 1 - mobile/lib/services/api.service.dart | 21 +- mobile/lib/services/auth.service.dart | 96 +++++++ mobile/lib/services/device.service.dart | 24 ++ mobile/lib/services/user.service.dart | 5 +- .../common/app_bar_dialog/app_bar_dialog.dart | 4 +- .../app_bar_dialog/app_bar_profile_info.dart | 7 +- .../widgets/forms/change_password_form.dart | 10 +- .../lib/widgets/forms/login/login_form.dart | 84 ++---- mobile/test/repository.mocks.dart | 6 + mobile/test/services/auth.service_test.dart | 118 +++++++++ 26 files changed, 619 insertions(+), 354 deletions(-) create mode 100644 mobile/lib/interfaces/auth.interface.dart create mode 100644 mobile/lib/interfaces/auth_api.interface.dart rename mobile/lib/models/{authentication/authentication_state.model.dart => auth/auth_state.model.dart} (74%) create mode 100644 mobile/lib/models/auth/login_response.model.dart create mode 100644 mobile/lib/providers/auth.provider.dart delete mode 100644 mobile/lib/providers/authentication.provider.dart create mode 100644 mobile/lib/repositories/auth.repository.dart create mode 100644 mobile/lib/repositories/auth_api.repository.dart create mode 100644 mobile/lib/services/auth.service.dart create mode 100644 mobile/lib/services/device.service.dart create mode 100644 mobile/test/services/auth.service_test.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 80514f1603..7a20c2a6a3 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -93,7 +93,7 @@ custom_lint: - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - lib/providers/asset_viewer/asset_people.provider.dart - - lib/providers/authentication.provider.dart + - lib/providers/auth.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - lib/providers/search/{search,search_filter}.provider.dart diff --git a/mobile/lib/interfaces/auth.interface.dart b/mobile/lib/interfaces/auth.interface.dart new file mode 100644 index 0000000000..e37323b994 --- /dev/null +++ b/mobile/lib/interfaces/auth.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAuthRepository implements IDatabaseRepository { + Future clearLocalData(); +} diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart new file mode 100644 index 0000000000..0a4b235ff3 --- /dev/null +++ b/mobile/lib/interfaces/auth_api.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/models/auth/login_response.model.dart'; + +abstract interface class IAuthApiRepository { + Future login(String email, String password); + + Future logout(); + + Future changePassword(String newPassword); +} diff --git a/mobile/lib/models/authentication/authentication_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart similarity index 74% rename from mobile/lib/models/authentication/authentication_state.model.dart rename to mobile/lib/models/auth/auth_state.model.dart index 9dcd320c81..fb65850f1d 100644 --- a/mobile/lib/models/authentication/authentication_state.model.dart +++ b/mobile/lib/models/auth/auth_state.model.dart @@ -1,62 +1,58 @@ -class AuthenticationState { +class AuthState { final String deviceId; final String userId; final String userEmail; final bool isAuthenticated; final String name; final bool isAdmin; - final bool shouldChangePassword; final String profileImagePath; - AuthenticationState({ + + AuthState({ required this.deviceId, required this.userId, required this.userEmail, required this.isAuthenticated, required this.name, required this.isAdmin, - required this.shouldChangePassword, required this.profileImagePath, }); - AuthenticationState copyWith({ + AuthState copyWith({ String? deviceId, String? userId, String? userEmail, bool? isAuthenticated, String? name, bool? isAdmin, - bool? shouldChangePassword, String? profileImagePath, }) { - return AuthenticationState( + return AuthState( deviceId: deviceId ?? this.deviceId, userId: userId ?? this.userId, userEmail: userEmail ?? this.userEmail, isAuthenticated: isAuthenticated ?? this.isAuthenticated, name: name ?? this.name, isAdmin: isAdmin ?? this.isAdmin, - shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword, profileImagePath: profileImagePath ?? this.profileImagePath, ); } @override String toString() { - return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)'; + return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AuthenticationState && + return other is AuthState && other.deviceId == deviceId && other.userId == userId && other.userEmail == userEmail && other.isAuthenticated == isAuthenticated && other.name == name && other.isAdmin == isAdmin && - other.shouldChangePassword == shouldChangePassword && other.profileImagePath == profileImagePath; } @@ -68,7 +64,6 @@ class AuthenticationState { isAuthenticated.hashCode ^ name.hashCode ^ isAdmin.hashCode ^ - shouldChangePassword.hashCode ^ profileImagePath.hashCode; } } diff --git a/mobile/lib/models/auth/login_response.model.dart b/mobile/lib/models/auth/login_response.model.dart new file mode 100644 index 0000000000..f1398418ca --- /dev/null +++ b/mobile/lib/models/auth/login_response.model.dart @@ -0,0 +1,30 @@ +class LoginResponse { + final String accessToken; + + final bool isAdmin; + + final String name; + + final String profileImagePath; + + final bool shouldChangePassword; + + final String userEmail; + + final String userId; + + LoginResponse({ + required this.accessToken, + required this.isAdmin, + required this.name, + required this.profileImagePath, + required this.shouldChangePassword, + required this.userEmail, + required this.userId, + }); + + @override + String toString() { + return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]'; + } +} diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 93e4c180fe..d9f8544af9 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final sharedUsers = useState(album.sharedUsers.toList()); final owner = album.owner.value; - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.activityEnabled); final isProcessing = useProcessingOverlay(); final isOwner = owner?.id == userId; diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index 97885ae4e6..4822c57a07 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget { () => ref.read(currentAlbumProvider.notifier).set(value), ), ); - final userId = ref.watch(authenticationProvider).userId; + final userId = ref.watch(authProvider).userId; final isProcessing = useProcessingOverlay(); Future onRemoveFromAlbumPressed(Iterable assets) async { diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index d23e25372c..d88c6cf366 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:logging/logging.dart'; @RoutePage() @@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final apiService = ref.watch(apiServiceProvider); final serverUrl = Store.tryGet(StoreKey.serverUrl); final endpoint = Store.tryGet(StoreKey.serverEndpoint); final accessToken = Store.tryGet(StoreKey.accessToken); @@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget { bool isAuthSuccess = false; if (accessToken != null && serverUrl != null && endpoint != null) { - apiService.setEndpoint(endpoint); - try { - isAuthSuccess = await ref - .read(authenticationProvider.notifier) - .setSuccessLoginInfo( + isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( accessToken: accessToken, - serverUrl: serverUrl, ); } catch (error, stackTrace) { log.severe( @@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget { log.severe( 'Unable to login using offline or online methods - Logging out completely', ); - ref.read(authenticationProvider.notifier).logout(); + ref.read(authProvider.notifier).logout(); context.replaceRoute(const LoginRoute()); return; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index c06a99da35..8cacb70eb2 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; @@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier { if (!_wasPaused) return; _wasPaused = false; - final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated; + final isAuthenticated = _ref.read(authProvider).isAuthenticated; // Needs to be logged in if (isAuthenticated) { @@ -85,7 +85,7 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; - if (_ref.read(authenticationProvider).isAuthenticated) { + if (_ref.read(authProvider).isAuthenticated) { // Do not cancel backup if manual upload is in progress if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart new file mode 100644 index 0000000000..5efbdab8d3 --- /dev/null +++ b/mobile/lib/providers/auth.provider.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/utils/hash.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier( + ref.watch(authServiceProvider), + ref.watch(apiServiceProvider), + ); +}); + +class AuthNotifier extends StateNotifier { + final AuthService _authService; + final ApiService _apiService; + final _log = Logger("AuthenticationNotifier"); + + static const Duration _timeoutDuration = Duration(seconds: 7); + + AuthNotifier( + this._authService, + this._apiService, + ) : super( + AuthState( + deviceId: "", + userId: "", + userEmail: "", + name: '', + profileImagePath: '', + isAdmin: false, + isAuthenticated: false, + ), + ); + + Future validateServerUrl(String url) { + return _authService.validateServerUrl(url); + } + + Future login(String email, String password) async { + final response = await _authService.login(email, password); + await saveAuthInfo(accessToken: response.accessToken); + return response; + } + + Future logout() async { + try { + await _authService.logout(); + } finally { + await _cleanUp(); + } + } + + Future _cleanUp() async { + state = AuthState( + deviceId: "", + userId: "", + userEmail: "", + name: '', + profileImagePath: '', + isAdmin: false, + isAuthenticated: false, + ); + } + + void updateUserProfileImagePath(String path) { + state = state.copyWith(profileImagePath: path); + } + + Future changePassword(String newPassword) async { + try { + await _authService.changePassword(newPassword); + return true; + } catch (_) { + return false; + } + } + + Future saveAuthInfo({ + required String accessToken, + }) async { + _apiService.setAccessToken(accessToken); + + // Get the deviceid from the store if it exists, otherwise generate a new one + String deviceId = + Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; + + User? user = Store.tryGet(StoreKey.currentUser); + + UserAdminResponseDto? userResponse; + UserPreferencesResponseDto? userPreferences; + try { + final responses = await Future.wait([ + _apiService.usersApi.getMyUser().timeout(_timeoutDuration), + _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration), + ]); + userResponse = responses[0] as UserAdminResponseDto; + userPreferences = responses[1] as UserPreferencesResponseDto; + } on ApiException catch (error, stackTrace) { + if (error.code == 401) { + _log.severe("Unauthorized access, token likely expired. Logging out."); + return false; + } + _log.severe( + "Error getting user information from the server [API EXCEPTION]", + stackTrace, + ); + } catch (error, stackTrace) { + _log.severe( + "Error getting user information from the server [CATCH ALL]", + error, + stackTrace, + ); + + if (kDebugMode) { + debugPrint( + "Error getting user information from the server [CATCH ALL] $error $stackTrace", + ); + } + } + + // If the user information is successfully retrieved, update the store + // Due to the flow of the code, this will always happen on first login + if (userResponse != null) { + Store.put(StoreKey.deviceId, deviceId); + Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); + Store.put( + StoreKey.currentUser, + User.fromUserDto(userResponse, userPreferences), + ); + Store.put(StoreKey.accessToken, accessToken); + + user = User.fromUserDto(userResponse, userPreferences); + } else { + _log.severe("Unable to get user information from the server."); + } + + // If the user is null, the login was not successful + // and we don't have a local copy of the user from a prior successful login + if (user == null) { + return false; + } + + state = state.copyWith( + isAuthenticated: true, + userId: user.id, + userEmail: user.email, + name: user.name, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + deviceId: deviceId, + ); + + return true; + } +} diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart deleted file mode 100644 index 60e31d707e..0000000000 --- a/mobile/lib/providers/authentication.provider.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_udid/flutter_udid.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/db.dart'; -import 'package:immich_mobile/utils/hash.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; - -class AuthenticationNotifier extends StateNotifier { - AuthenticationNotifier( - this._apiService, - this._db, - this._ref, - ) : super( - AuthenticationState( - deviceId: "", - userId: "", - userEmail: "", - name: '', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - isAuthenticated: false, - ), - ); - - final ApiService _apiService; - final Isar _db; - final StateNotifierProviderRef - _ref; - final _log = Logger("AuthenticationNotifier"); - - static const Duration _timeoutDuration = Duration(seconds: 7); - - Future login( - String email, - String password, - String serverUrl, - ) async { - try { - // Resolve API server endpoint from user provided serverUrl - await _apiService.resolveAndSetEndpoint(serverUrl); - await _apiService.serverInfoApi.pingServer(); - } catch (e) { - debugPrint('Invalid Server Endpoint Url $e'); - return false; - } - - // Make sign-in request - DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); - - if (Platform.isIOS) { - var iosInfo = await deviceInfoPlugin.iosInfo; - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceModel', iosInfo.utsname.machine); - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceType', 'iOS'); - } else { - var androidInfo = await deviceInfoPlugin.androidInfo; - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceModel', androidInfo.model); - _apiService.authenticationApi.apiClient - .addDefaultHeader('deviceType', 'Android'); - } - - try { - var loginResponse = await _apiService.authenticationApi.login( - LoginCredentialDto( - email: email, - password: password, - ), - ); - - if (loginResponse == null) { - debugPrint('Login Response is null'); - return false; - } - - return setSuccessLoginInfo( - accessToken: loginResponse.accessToken, - serverUrl: serverUrl, - ); - } catch (e) { - debugPrint("Error logging in $e"); - return false; - } - } - - Future logout() async { - var log = Logger('AuthenticationNotifier'); - try { - String? userEmail = Store.tryGet(StoreKey.currentUser)?.email; - - await _apiService.authenticationApi - .logout() - .timeout(_timeoutDuration) - .then((_) => log.info("Logout was successful for $userEmail")) - .onError( - (error, stackTrace) => - log.severe("Logout failed for $userEmail", error, stackTrace), - ); - } catch (e, stack) { - log.severe('Logout failed', e, stack); - } finally { - await Future.wait([ - clearAssetsAndAlbums(_db), - Store.delete(StoreKey.currentUser), - Store.delete(StoreKey.accessToken), - ]); - _ref.invalidate(albumProvider); - - state = state.copyWith( - deviceId: "", - userId: "", - userEmail: "", - name: '', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - isAuthenticated: false, - ); - } - } - - updateUserProfileImagePath(String path) { - state = state.copyWith(profileImagePath: path); - } - - Future changePassword(String newPassword) async { - try { - await _apiService.usersApi.updateMyUser( - UserUpdateMeDto( - password: newPassword, - ), - ); - - state = state.copyWith(shouldChangePassword: false); - - return true; - } catch (e) { - debugPrint("Error changing password $e"); - return false; - } - } - - Future setSuccessLoginInfo({ - required String accessToken, - required String serverUrl, - }) async { - _apiService.setAccessToken(accessToken); - - // Get the deviceid from the store if it exists, otherwise generate a new one - String deviceId = - Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; - - bool shouldChangePassword = false; - User? user = Store.tryGet(StoreKey.currentUser); - - UserAdminResponseDto? userResponse; - UserPreferencesResponseDto? userPreferences; - try { - final responses = await Future.wait([ - _apiService.usersApi.getMyUser().timeout(_timeoutDuration), - _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration), - ]); - userResponse = responses[0] as UserAdminResponseDto; - userPreferences = responses[1] as UserPreferencesResponseDto; - } on ApiException catch (error, stackTrace) { - if (error.code == 401) { - _log.severe("Unauthorized access, token likely expired. Logging out."); - return false; - } - _log.severe( - "Error getting user information from the server [API EXCEPTION]", - stackTrace, - ); - } catch (error, stackTrace) { - _log.severe( - "Error getting user information from the server [CATCH ALL]", - error, - stackTrace, - ); - debugPrint( - "Error getting user information from the server [CATCH ALL] $error $stackTrace", - ); - } - - // If the user information is successfully retrieved, update the store - // Due to the flow of the code, this will always happen on first login - if (userResponse != null) { - Store.put(StoreKey.deviceId, deviceId); - Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - Store.put( - StoreKey.currentUser, - User.fromUserDto(userResponse, userPreferences), - ); - Store.put(StoreKey.serverUrl, serverUrl); - Store.put(StoreKey.accessToken, accessToken); - - shouldChangePassword = userResponse.shouldChangePassword; - user = User.fromUserDto(userResponse, userPreferences); - } else { - _log.severe("Unable to get user information from the server."); - } - - // If the user is null, the login was not successful - // and we don't have a local copy of the user from a prior successful login - if (user == null) { - return false; - } - - state = state.copyWith( - isAuthenticated: true, - userId: user.id, - userEmail: user.email, - name: user.name, - profileImagePath: user.profileImagePath, - isAdmin: user.isAdmin, - shouldChangePassword: shouldChangePassword, - deviceId: deviceId, - ); - - return true; - } -} - -final authenticationProvider = - StateNotifierProvider((ref) { - return AuthenticationNotifier( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), - ref, - ); -}); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index dc6d2f7cc8..aab367485c 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier { final log = Logger('BackupNotifier'); final BackupService _backupService; final ServerInfoService _serverInfoService; - final AuthenticationState _authState; + final AuthState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; final Isar _db; @@ -765,7 +765,7 @@ final backupProvider = return BackupNotifier( ref.watch(backupServiceProvider), ref.watch(serverInfoServiceProvider), - ref.watch(authenticationProvider), + ref.watch(authProvider), ref.watch(backgroundServiceProvider), ref.watch(galleryPermissionNotifier.notifier), ref.watch(dbProvider), diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6216a5de64..6889db7b7f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier { /// Connects websocket to server unless already connected void connect() { if (state.isConnected) return; - final authenticationState = _ref.read(authenticationProvider); + final authenticationState = _ref.read(authProvider); if (authenticationState.isAuthenticated) { try { diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart new file mode 100644 index 0000000000..f003890696 --- /dev/null +++ b/mobile/lib/repositories/auth.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; + +final authRepositoryProvider = Provider( + (ref) => AuthRepository(ref.watch(dbProvider)), +); + +class AuthRepository extends DatabaseRepository implements IAuthRepository { + AuthRepository(super.db); + + @override + Future clearLocalData() { + return db.writeTxn(() async { + await db.assets.clear(); + await db.exifInfos.clear(); + await db.albums.clear(); + await db.eTags.clear(); + await db.users.clear(); + }); + } +} diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart new file mode 100644 index 0000000000..faa2916adb --- /dev/null +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -0,0 +1,56 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; + +final authApiRepositoryProvider = + Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider))); + +class AuthApiRepository extends ApiRepository implements IAuthApiRepository { + final ApiService _apiService; + + AuthApiRepository(this._apiService); + + @override + Future changePassword(String newPassword) async { + await _apiService.usersApi.updateMyUser( + UserUpdateMeDto( + password: newPassword, + ), + ); + } + + @override + Future login(String email, String password) async { + final loginResponseDto = await checkNull( + _apiService.authenticationApi.login( + LoginCredentialDto( + email: email, + password: password, + ), + ), + ); + + return _mapLoginReponse(loginResponseDto); + } + + @override + Future logout() async { + await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7)); + } + + _mapLoginReponse(LoginResponseDto dto) { + return LoginResponse( + accessToken: dto.accessToken, + isAdmin: dto.isAdmin, + name: dto.name, + profileImagePath: dto.profileImagePath, + shouldChangePassword: dto.shouldChangePassword, + userEmail: dto.userEmail, + userId: dto.userId, + ); + } +} diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart index f9ee1426bb..3eb74621fa 100644 --- a/mobile/lib/repositories/database.repository.dart +++ b/mobile/lib/repositories/database.repository.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:isar/isar.dart'; diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 515023d163..bd754ac214 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -69,7 +70,7 @@ class ApiService implements Authentication { final endpoint = await _resolveEndpoint(serverUrl); setEndpoint(endpoint); - // Save in hivebox for next startup + // Save in local database for next startup Store.put(StoreKey.serverEndpoint, endpoint); return endpoint; } @@ -148,11 +149,27 @@ class ApiService implements Authentication { return ""; } - setAccessToken(String accessToken) { + void setAccessToken(String accessToken) { _accessToken = accessToken; Store.put(StoreKey.accessToken, accessToken); } + Future setDeviceInfoHeader() async { + DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + + if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + authenticationApi.apiClient + .addDefaultHeader('deviceModel', iosInfo.utsname.machine); + authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS'); + } else { + final androidInfo = await deviceInfoPlugin.androidInfo; + authenticationApi.apiClient + .addDefaultHeader('deviceModel', androidInfo.model); + authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android'); + } + } + static Map getRequestHeaders() { var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart new file mode 100644 index 0000000000..e61f485987 --- /dev/null +++ b/mobile/lib/services/auth.service.dart @@ -0,0 +1,96 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/login_response.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/auth.repository.dart'; +import 'package:immich_mobile/repositories/auth_api.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:logging/logging.dart'; + +final authServiceProvider = Provider( + (ref) => AuthService( + ref.watch(authApiRepositoryProvider), + ref.watch(authRepositoryProvider), + ref.watch(apiServiceProvider), + ), +); + +class AuthService { + final IAuthApiRepository _authApiRepository; + final IAuthRepository _authRepository; + final ApiService _apiService; + + final _log = Logger("AuthService"); + + AuthService( + this._authApiRepository, + this._authRepository, + this._apiService, + ); + + /// Validates the provided server URL by resolving and setting the endpoint. + /// Also sets the device info header and stores the valid URL. + /// + /// [url] - The server URL to be validated. + /// + /// Returns the validated and resolved server URL as a [String]. + /// + /// Throws an exception if the URL cannot be resolved or set. + Future validateServerUrl(String url) async { + final validUrl = await _apiService.resolveAndSetEndpoint(url); + await _apiService.setDeviceInfoHeader(); + Store.put(StoreKey.serverUrl, validUrl); + + return validUrl; + } + + Future login(String email, String password) { + return _authApiRepository.login(email, password); + } + + /// Performs user logout operation by making a server request and clearing local data. + /// + /// This method attempts to log out the user through the authentication API repository. + /// If the server request fails, the error is logged but local data is still cleared. + /// The local data cleanup is guaranteed to execute regardless of the server request outcome. + /// + /// Throws any unhandled exceptions from the API request or local data clearing operations. + Future logout() async { + try { + await _authApiRepository.logout(); + } catch (error, stackTrace) { + _log.severe("Error logging out", error, stackTrace); + } finally { + await clearLocalData(); + } + } + + /// Clears all local authentication-related data. + /// + /// This method performs a concurrent deletion of: + /// - Authentication repository data + /// - Current user information + /// - Access token + /// - Asset ETag + /// + /// All deletions are executed in parallel using [Future.wait]. + Future clearLocalData() { + return Future.wait([ + _authRepository.clearLocalData(), + Store.delete(StoreKey.currentUser), + Store.delete(StoreKey.accessToken), + Store.delete(StoreKey.assetETag), + ]); + } + + Future changePassword(String newPassword) { + try { + return _authApiRepository.changePassword(newPassword); + } catch (error, stackTrace) { + _log.severe("Error changing password", error, stackTrace); + rethrow; + } + } +} diff --git a/mobile/lib/services/device.service.dart b/mobile/lib/services/device.service.dart new file mode 100644 index 0000000000..e1676d5683 --- /dev/null +++ b/mobile/lib/services/device.service.dart @@ -0,0 +1,24 @@ +import 'package:flutter_udid/flutter_udid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; + +final deviceServiceProvider = Provider((ref) => DeviceService()); + +class DeviceService { + DeviceService(); + + createDeviceId() { + return FlutterUdid.consistentUdid; + } + + /// Returns the device ID from local storage or creates a new one if not found. + /// + /// This method first attempts to retrieve the device ID from the local store using + /// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a + /// new device ID by calling [createDeviceId]. + /// + /// Returns a [String] representing the device's unique identifier. + String getDeviceId() { + return Store.tryGet(StoreKey.deviceId) ?? createDeviceId(); + } +} diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 4c2b3cbbd0..13adcc4e7a 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -35,8 +35,9 @@ class UserService { this._syncService, ); - Future> getUsers({bool self = false}) => - _userRepository.getAll(self: self); + Future> getUsers({bool self = false}) { + return _userRepository.getAll(self: self); + } Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 471014608a..a83afc00b3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { onOk: () async { isLoggingOut.value = true; await ref - .read(authenticationProvider.notifier) + .read(authProvider.notifier) .logout() .whenComplete(() => isLoggingOut.value = false); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index a40dcf914e..f0006d1ada 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -7,8 +7,7 @@ import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; class AppBarProfileInfoBox extends HookConsumerWidget { @@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - AuthenticationState authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; final user = Store.tryGet(StoreKey.currentUser); @@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { if (success) { final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; - ref.watch(authenticationProvider.notifier).updateUserProfileImagePath( + ref.watch(authProvider.notifier).updateUserProfileImagePath( profileImagePath, ); if (user != null) { diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index 98ce66d2d1..fbb8fd927b 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget { useTextEditingController.fromValue(TextEditingValue.empty); final confirmPasswordController = useTextEditingController.fromValue(TextEditingValue.empty); - final authState = ref.watch(authenticationProvider); + final authState = ref.watch(authProvider); final formKey = GlobalKey(); return Center( @@ -73,13 +73,11 @@ class ChangePasswordForm extends HookConsumerWidget { onPressed: () async { if (formKey.currentState!.validate()) { var isSuccess = await ref - .read(authenticationProvider.notifier) + .read(authProvider.notifier) .changePassword(passwordController.value.text); if (isSuccess) { - await ref - .read(authenticationProvider.notifier) - .logout(); + await ref.read(authProvider.notifier).logout(); ref .read(manualUploadProvider.notifier) diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe195..30b6a74bb1 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -11,9 +11,7 @@ import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/authentication.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; @@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final usernameController = + final emailController = useTextEditingController.fromValue(TextEditingValue.empty); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final apiService = ref.watch(apiServiceProvider); final emailFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode(); @@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget { /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise - Future getServerLoginCredential() async { + Future getServerAuthSettings() async { final serverUrl = sanitizeUrl(serverEndpointController.text); // Guard empty URL @@ -95,13 +92,12 @@ class LoginForm extends HookConsumerWidget { msg: "login_form_server_empty".tr(), toastType: ToastType.error, ); - - return false; } try { isLoadingServer.value = true; - final endpoint = await apiService.resolveAndSetEndpoint(serverUrl); + final endpoint = + await ref.read(authProvider.notifier).validateServerUrl(serverUrl); // Fetch and load server config and features await ref.read(serverInfoProvider.notifier).getServerInfo(); @@ -127,7 +123,6 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } on HandshakeException { ImmichToast.show( context: context, @@ -138,7 +133,6 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } catch (e) { ImmichToast.show( context: context, @@ -149,11 +143,9 @@ class LoginForm extends HookConsumerWidget { isOauthEnable.value = false; isPasswordLoginEnable.value = true; isLoadingServer.value = false; - return false; } isLoadingServer.value = false; - return true; } useEffect( @@ -168,67 +160,50 @@ class LoginForm extends HookConsumerWidget { ); populateTestLoginInfo() { - usernameController.text = 'demo@immich.app'; + emailController.text = 'demo@immich.app'; passwordController.text = 'demo'; serverEndpointController.text = 'https://demo.immich.app'; } populateTestLoginInfo1() { - usernameController.text = 'testuser@email.com'; + emailController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://10.1.15.216:3000/api'; } login() async { TextInput.finishAutofillContext(); - // Start loading - isLoading.value = true; - // This will remove current cache asset state of previous user login. - ref.read(assetProvider.notifier).clearAllAsset(); + isLoading.value = true; // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); try { - final isAuthenticated = - await ref.read(authenticationProvider.notifier).login( - usernameController.text, - passwordController.text, - sanitizeUrl(serverEndpointController.text), - ); - if (isAuthenticated) { - // Resume backup (if enable) then navigate - if (ref.read(authenticationProvider).shouldChangePassword && - !ref.read(authenticationProvider).isAdmin) { - context.pushRoute(const ChangePasswordRoute()); - } else { - final hasPermission = await ref - .read(galleryPermissionNotifier.notifier) - .hasPermission; - if (hasPermission) { - // Don't resume the backup until we have gallery permission - ref.read(backupProvider.notifier).resumeBackup(); - } - context.replaceRoute(const TabControllerRoute()); - } + final result = await ref.read(authProvider.notifier).login( + emailController.text, + passwordController.text, + ); + + if (result.shouldChangePassword && !result.isAdmin) { + context.pushRoute(const ChangePasswordRoute()); } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); + context.replaceRoute(const TabControllerRoute()); } + } catch (error) { + ImmichToast.show( + context: context, + msg: "login_form_failed_login".tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); } finally { - // Make sure we stop loading isLoading.value = false; } } oAuthLogin() async { var oAuthService = ref.watch(oAuthServiceProvider); - ref.watch(assetProvider.notifier).clearAllAsset(); String? oAuthServerUrl; try { @@ -262,11 +237,8 @@ class LoginForm extends HookConsumerWidget { "Finished OAuth login with response: ${loginResponseDto.userEmail}", ); - final isSuccess = await ref - .watch(authenticationProvider.notifier) - .setSuccessLoginInfo( + final isSuccess = await ref.watch(authProvider.notifier).saveAuthInfo( accessToken: loginResponseDto.accessToken, - serverUrl: sanitizeUrl(serverEndpointController.text), ); if (isSuccess) { @@ -309,7 +281,7 @@ class LoginForm extends HookConsumerWidget { ServerEndpointInput( controller: serverEndpointController, focusNode: serverEndpointFocusNode, - onSubmit: getServerLoginCredential, + onSubmit: getServerAuthSettings, ), const SizedBox(height: 18), Row( @@ -344,7 +316,7 @@ class LoginForm extends HookConsumerWidget { ), ), onPressed: - isLoadingServer.value ? null : getServerLoginCredential, + isLoadingServer.value ? null : getServerAuthSettings, icon: const Icon(Icons.arrow_forward_rounded), label: const Text( 'login_form_next_button', @@ -402,7 +374,7 @@ class LoginForm extends HookConsumerWidget { if (isPasswordLoginEnable.value) ...[ const SizedBox(height: 18), EmailInput( - controller: usernameController, + controller: emailController, focusNode: emailFocusNode, onSubmit: passwordFocusNode.requestFocus, ), diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index c76a003eec..3dda932cac 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; @@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} + +class MockAuthApiRepository extends Mock implements IAuthApiRepository {} + +class MockAuthRepository extends Mock implements IAuthRepository {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart new file mode 100644 index 0000000000..b864babb14 --- /dev/null +++ b/mobile/test/services/auth.service_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; +import '../test_utils.dart'; + +void main() { + late AuthService sut; + late MockAuthApiRepository authApiRepository; + late MockAuthRepository authRepository; + late MockApiService apiService; + + setUp(() async { + authApiRepository = MockAuthApiRepository(); + authRepository = MockAuthRepository(); + apiService = MockApiService(); + sut = AuthService(authApiRepository, authRepository, apiService); + }); + + group('validateServerUrl', () { + setUpAll(() async { + WidgetsFlutterBinding.ensureInitialized(); + final db = await TestUtils.initIsar(); + db.writeTxnSync(() => db.clearSync()); + Store.init(db); + }); + + test('Should resolve HTTP endpoint', () async { + const testUrl = 'http://ip:2283'; + const resolvedUrl = 'http://ip:2283/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should resolve HTTPS endpoint', () async { + const testUrl = 'https://immich.domain.com'; + const resolvedUrl = 'https://immich.domain.com/api'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenAnswer((_) async => resolvedUrl); + when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {}); + + final result = await sut.validateServerUrl(testUrl); + + expect(result, resolvedUrl); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verify(() => apiService.setDeviceInfoHeader()).called(1); + }); + + test('Should throw error on invalid URL', () async { + const testUrl = 'invalid-url'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenThrow(Exception('Invalid URL')); + + expect( + () async => await sut.validateServerUrl(testUrl), + throwsA(isA()), + ); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verifyNever(() => apiService.setDeviceInfoHeader()); + }); + + test('Should throw error on unreachable server', () async { + const testUrl = 'https://unreachable.server'; + + when(() => apiService.resolveAndSetEndpoint(testUrl)) + .thenThrow(Exception('Server is not reachable')); + + expect( + () async => await sut.validateServerUrl(testUrl), + throwsA(isA()), + ); + + verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1); + verifyNever(() => apiService.setDeviceInfoHeader()); + }); + }); + + group('logout', () { + test('Should logout user', () async { + when(() => authApiRepository.logout()).thenAnswer((_) async => {}); + when(() => authRepository.clearLocalData()) + .thenAnswer((_) => Future.value(null)); + + await sut.logout(); + + verify(() => authApiRepository.logout()).called(1); + verify(() => authRepository.clearLocalData()).called(1); + }); + + test('Should clear local data even on server error', () async { + when(() => authApiRepository.logout()) + .thenThrow(Exception('Server error')); + when(() => authRepository.clearLocalData()) + .thenAnswer((_) => Future.value(null)); + + await sut.logout(); + + verify(() => authApiRepository.logout()).called(1); + verify(() => authRepository.clearLocalData()).called(1); + }); + }); +} From b4c96a09fb169f5a1aeb5f6c2fe96b844c08c28b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Nov 2024 13:36:12 -0600 Subject: [PATCH 499/599] chore: follow up on auth refactoring (#14367) * chore: follow up on auth refactoring * remove async --- mobile/lib/repositories/auth.repository.dart | 14 ++++++++------ mobile/lib/services/auth.service.dart | 4 +++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index f003890696..ababf35c9b 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -17,12 +17,14 @@ class AuthRepository extends DatabaseRepository implements IAuthRepository { @override Future clearLocalData() { - return db.writeTxn(() async { - await db.assets.clear(); - await db.exifInfos.clear(); - await db.albums.clear(); - await db.eTags.clear(); - await db.users.clear(); + return db.writeTxn(() { + return Future.wait([ + db.assets.clear(), + db.exifInfos.clear(), + db.albums.clear(), + db.eTags.clear(), + db.users.clear(), + ]); }); } } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index e61f485987..fa6e282e63 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -63,7 +63,9 @@ class AuthService { } catch (error, stackTrace) { _log.severe("Error logging out", error, stackTrace); } finally { - await clearLocalData(); + await clearLocalData().catchError((error, stackTrace) { + _log.severe("Error clearing local data", error, stackTrace); + }); } } From 3d61548d7da9a2985964ee2c081eec9aacc387b2 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:45:52 +0100 Subject: [PATCH 500/599] fix(web): resolve issues with user usage statistics and refactor (#14374) --- i18n/en.json | 1 + .../user-usage-statistic.svelte | 106 ++++++++---------- 2 files changed, 49 insertions(+), 58 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 9224597feb..277db70a23 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1306,6 +1306,7 @@ "view_all_users": "View all users", "view_in_timeline": "View in timeline", "view_links": "View links", + "view_name": "View", "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", "view_stack": "View Stack", diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index 8833d266ea..f7de1d8f64 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -1,4 +1,5 @@ -{#snippet row(viewName: string, imageCount: number, videoCount: number, totalCount: number)} - {viewName} - {imageCount} - {videoCount} - {totalCount} +{#snippet row(viewName: string, stats: AssetStatsResponseDto)} + + {viewName} + {stats.images.toLocaleString($locale)} + {stats.videos.toLocaleString($locale)} + {stats.total.toLocaleString($locale)} + {/snippet}

    {$t('photos_and_videos')}

    - - - - - - - - - - - +
    {$t('view').toLocaleString()}{$t('photos').toLocaleString()}{$t('videos').toLocaleString()}{$t('total').toLocaleString()}
    + - {@render row($t('timeline'), timelineStats.images, timelineStats.videos, timelineStats.total)} - - - - {@render row($t('favorites'), favoriteStats.images, favoriteStats.videos, favoriteStats.total)} - - - - {@render row($t('archive'), archiveStats.images, archiveStats.videos, archiveStats.total)} - - - - {@render row($t('trash'), trashStats.images, trashStats.videos, trashStats.total)} - - -
    + + {$t('view_name')} + {$t('photos')} + {$t('videos')} + {$t('total')} + + + + {@render row($t('timeline'), timelineStats)} + {@render row($t('favorites'), favoriteStats)} + {@render row($t('archive'), archiveStats)} + {@render row($t('trash'), trashStats)} + + +

    {$t('albums')}

    - - - - - - - - - +
    {$t('owned')}{$t('shared')}
    + - - - - -
    {albumStats.owned.toLocaleString()}{albumStats.shared.toLocaleString()}
    + + {$t('owned')} + {$t('shared')} + + + + + {albumStats.owned.toLocaleString($locale)} + {albumStats.shared.toLocaleString($locale)} + + + +
    From 56d2309122ff537c4636e5a7abb3fcd07935c444 Mon Sep 17 00:00:00 2001 From: System Tester Date: Fri, 29 Nov 2024 14:34:18 +1000 Subject: [PATCH 501/599] fix: ConnectivityResult.wifi regression (#14401) --- mobile/lib/providers/backup/backup_verification.provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index 7b8e7b8c4b..5881814320 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification { return; } final connection = await Connectivity().checkConnectivity(); - if (connection.contains(ConnectivityResult.wifi)) { + if (!connection.contains(ConnectivityResult.wifi)) { if (context.mounted) { ImmichToast.show( context: context, From 4eb7758f56089481a7319a0a0fc2853a013dc2ea Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 2 Dec 2024 03:21:08 +0800 Subject: [PATCH 502/599] feat(server): specify names for thumbnail files (#14425) --- server/src/services/asset-media.service.spec.ts | 3 +++ server/src/services/asset-media.service.ts | 6 +++++- server/src/utils/file.ts | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index d68140367d..da7e23be54 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -571,6 +571,7 @@ describe(AssetMediaService.name, () => { path: '/path/to/preview.jpg', cacheControl: CacheControl.PRIVATE_WITH_CACHE, contentType: 'image/jpeg', + fileName: 'asset-id_thumbnail.jpg', }), ); }); @@ -585,6 +586,7 @@ describe(AssetMediaService.name, () => { path: assetStub.image.files[0].path, cacheControl: CacheControl.PRIVATE_WITH_CACHE, contentType: 'image/jpeg', + fileName: 'asset-id_preview.jpg', }), ); }); @@ -599,6 +601,7 @@ describe(AssetMediaService.name, () => { path: assetStub.image.files[1].path, cacheControl: CacheControl.PRIVATE_WITH_CACHE, contentType: 'application/octet-stream', + fileName: 'asset-id_thumbnail.ext', }), ); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 2424c93e44..e96d1fd0a6 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -27,7 +27,7 @@ import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; import { requireUploadAccess } from 'src/utils/access'; import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; -import { ImmichFileResponse } from 'src/utils/file'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; @@ -217,8 +217,12 @@ export class AssetMediaService extends BaseService { if (!filepath) { throw new NotFoundException('Asset media not found'); } + let fileName = getFileNameWithoutExtension(asset.originalFileName); + fileName += `_${size}`; + fileName += getFilenameExtension(filepath); return new ImmichFileResponse({ + fileName, path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: CacheControl.PRIVATE_WITH_CACHE, diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 3b26c3e1ba..ba487840e5 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -24,6 +24,7 @@ export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; public readonly cacheControl!: CacheControl; + public readonly fileName?: string; constructor(response: ImmichFileResponse) { Object.assign(this, response); @@ -56,6 +57,9 @@ export const sendFile = async ( } res.header('Content-Type', file.contentType); + if (file.fileName) { + res.header('Content-Disposition', `inline; filename="${file.fileName}"`); + } const options: SendFileOptions = { dotfiles: 'allow' }; if (!isAbsolute(file.path)) { From 1bb6926b5e926db09a4f11232fcd9a0b2518a42c Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 2 Dec 2024 09:33:44 -0600 Subject: [PATCH 503/599] chore(mobile): Add const linter (#14447) --- mobile/analysis_options.yaml | 1 + mobile/lib/pages/albums/albums.page.dart | 10 +++++----- .../lib/pages/common/album_options.page.dart | 2 +- .../album_shared_user_selection.page.dart | 2 +- mobile/lib/pages/library/library.page.dart | 10 +++++----- .../places/places_collection.page.dart | 2 +- mobile/lib/pages/search/search.page.dart | 20 ++++++++++--------- .../lib/repositories/auth_api.repository.dart | 4 +++- mobile/lib/services/api.service.dart | 2 +- mobile/lib/utils/immich_app_theme.dart | 8 ++++---- .../widgets/album/album_viewer_appbar.dart | 4 ++-- .../asset_viewer/bottom_gallery_bar.dart | 2 +- .../common/app_bar_dialog/app_bar_dialog.dart | 4 ++-- .../modules/map/map_theme_override_test.dart | 2 +- 14 files changed, 39 insertions(+), 34 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 7a20c2a6a3..2b4b810f2a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -28,6 +28,7 @@ linter: use_build_context_synchronously: false require_trailing_commas: true unrelated_type_equality_checks: true + prefer_const_constructors: true # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart index e466149ac3..6f7d99b727 100644 --- a/mobile/lib/pages/albums/albums.page.dart +++ b/mobile/lib/pages/albums/albums.page.dart @@ -78,7 +78,7 @@ class AlbumsPage extends HookConsumerWidget { showUploadButton: false, actions: [ IconButton( - icon: Icon( + icon: const Icon( Icons.add_rounded, size: 28, ), @@ -112,13 +112,13 @@ class AlbumsPage extends HookConsumerWidget { ], begin: Alignment.topLeft, end: Alignment.bottomRight, - transform: GradientRotation(0.5 * pi), + transform: const GradientRotation(0.5 * pi), ), ), child: TextField( autofocus: false, decoration: InputDecoration( - contentPadding: EdgeInsets.all(16), + contentPadding: const EdgeInsets.all(16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide( @@ -362,13 +362,13 @@ class SortButton extends ConsumerWidget { return MenuAnchor( style: MenuStyle( - elevation: WidgetStatePropertyAll(1), + elevation: const WidgetStatePropertyAll(1), shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), ), - padding: WidgetStatePropertyAll( + padding: const WidgetStatePropertyAll( EdgeInsets.all(4), ), ), diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index d9f8544af9..93dfad00c4 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -49,7 +49,7 @@ class AlbumOptionsPage extends HookConsumerWidget { if (isSuccess) { context.navigateTo( - TabControllerRoute(children: [AlbumsRoute()]), + const TabControllerRoute(children: [AlbumsRoute()]), ); } else { showErrorMessage(); diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/common/album_shared_user_selection.page.dart index 9dadef1a76..ed8a45194d 100644 --- a/mobile/lib/pages/common/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_shared_user_selection.page.dart @@ -33,7 +33,7 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { if (newAlbum != null) { ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); context.maybePop(true); - context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); + context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); } ScaffoldMessenger( diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 837005c175..92fe8cec17 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -28,7 +28,7 @@ class LibraryPage extends ConsumerWidget { ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); return Scaffold( - appBar: ImmichAppBar(), + appBar: const ImmichAppBar(), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ListView( @@ -81,7 +81,7 @@ class LibraryPage extends ConsumerWidget { ], ), const SizedBox(height: 12), - QuickAccessButtons(), + const QuickAccessButtons(), const SizedBox( height: 32, ), @@ -122,8 +122,8 @@ class QuickAccessButtons extends ConsumerWidget { ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + topLeft: const Radius.circular(20), + topRight: const Radius.circular(20), bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0), bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), ), @@ -173,7 +173,7 @@ class PartnerList extends ConsumerWidget { right: 18.0, ), leading: userAvatar(context, partner, radius: 16), - title: Text( + title: const Text( "partner_list_user_photos", style: TextStyle( fontWeight: FontWeight.w500, diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index 3e4f9f6a1d..f42febc373 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -60,7 +60,7 @@ class PlacesCollectionPage extends HookConsumerWidget { ); }, error: (error, stask) => const Text('Error getting places'), - loading: () => Center(child: const CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator()), ), ], ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 82d7c0a168..9f2ddee446 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -499,8 +499,8 @@ class SearchPage extends HookConsumerWidget { controller: textSearchController, decoration: InputDecoration( contentPadding: prefilter != null - ? EdgeInsets.only(left: 24) - : EdgeInsets.all(8), + ? const EdgeInsets.only(left: 24) + : const EdgeInsets.all(8), prefixIcon: prefilter != null ? null : Icon( @@ -647,7 +647,9 @@ class SearchResultGrid extends StatelessWidget { stackEnabled: false, emptyIndicator: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: !isSearching ? SearchEmptyContent() : SizedBox.shrink(), + child: !isSearching + ? const SearchEmptyContent() + : const SizedBox.shrink(), ), ), ), @@ -666,7 +668,7 @@ class SearchEmptyContent extends StatelessWidget { child: ListView( shrinkWrap: false, children: [ - SizedBox(height: 40), + const SizedBox(height: 40), Center( child: Image.asset( context.isDarkTheme @@ -675,15 +677,15 @@ class SearchEmptyContent extends StatelessWidget { height: 125, ), ), - SizedBox(height: 16), + const SizedBox(height: 16), Center( child: Text( "Search for your photos and videos", style: context.textTheme.labelLarge, ), ), - SizedBox(height: 32), - QuickLinkList(), + const SizedBox(height: 32), + const QuickLinkList(), ], ), ); @@ -725,13 +727,13 @@ class QuickLinkList extends StatelessWidget { QuickLink( title: 'videos'.tr(), icon: Icons.play_circle_outline_rounded, - onTap: () => context.pushRoute(AllVideosRoute()), + onTap: () => context.pushRoute(const AllVideosRoute()), ), QuickLink( title: 'favorites'.tr(), icon: Icons.favorite_border_rounded, isBottom: true, - onTap: () => context.pushRoute(FavoritesRoute()), + onTap: () => context.pushRoute(const FavoritesRoute()), ), ], ), diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart index faa2916adb..f3a1d52de3 100644 --- a/mobile/lib/repositories/auth_api.repository.dart +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -39,7 +39,9 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository { @override Future logout() async { - await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7)); + await _apiService.authenticationApi + .logout() + .timeout(const Duration(seconds: 7)); } _mapLoginReponse(LoginResponseDto dto) { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bd754ac214..63cd3f9f8c 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -104,7 +104,7 @@ class ApiService implements Authentication { try { await setEndpoint(serverUrl); - await serverInfoApi.pingServer().timeout(Duration(seconds: 5)); + await serverInfoApi.pingServer().timeout(const Duration(seconds: 5)); } on TimeoutException catch (_) { return false; } on SocketException catch (_) { diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index da47490651..2ca4fe3aff 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -198,7 +198,7 @@ ThemeData getThemeData({ scrolledUnderElevation: 0, centerTitle: true, ), - textTheme: TextTheme( + textTheme: const TextTheme( displayLarge: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, @@ -211,15 +211,15 @@ ThemeData getThemeData({ fontSize: 12, fontWeight: FontWeight.bold, ), - titleSmall: const TextStyle( + titleSmall: TextStyle( fontSize: 16.0, fontWeight: FontWeight.bold, ), - titleMedium: const TextStyle( + titleMedium: TextStyle( fontSize: 18.0, fontWeight: FontWeight.bold, ), - titleLarge: const TextStyle( + titleLarge: TextStyle( fontSize: 26.0, fontWeight: FontWeight.bold, ), diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 89528cc4da..525bfa1242 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -46,7 +46,7 @@ class AlbumViewerAppbar extends HookConsumerWidget final bool success; if (album.shared) { success = await ref.watch(albumProvider.notifier).deleteAlbum(album); - context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); + context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); } else { success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context @@ -113,7 +113,7 @@ class AlbumViewerAppbar extends HookConsumerWidget await ref.watch(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); + context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); } else { context.pop(); ImmichToast.show( diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index f698e866ad..82ca295d8a 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -338,7 +338,7 @@ class BottomGalleryBar extends ConsumerWidget { ), position: DecorationPosition.background, child: Padding( - padding: EdgeInsets.only(top: 40.0), + padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ if (showVideoPlayerControls) const VideoControls(), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index a83afc00b3..218e17cbe1 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -143,9 +143,9 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); }, trailing: isLoggingOut.value - ? SizedBox.square( + ? const SizedBox.square( dimension: 20, - child: const CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator(strokeWidth: 2), ) : null, ); diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index b462ede4c5..c21f9bf166 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -27,7 +27,7 @@ void main() { mapStateNotifier = MockMapStateNotifier(mapState); overrides = [ mapStateNotifierProvider.overrideWith(() => mapStateNotifier), - localeProvider.overrideWithValue(Locale("en")), + localeProvider.overrideWithValue(const Locale("en")), ]; }); From ba71fd42da5cd948d51fb656bd94dd1336493069 Mon Sep 17 00:00:00 2001 From: System Tester Date: Tue, 3 Dec 2024 04:14:12 +1000 Subject: [PATCH 504/599] chore(mobile): added 'corrupt asset check' translation item (#14402) --- mobile/assets/i18n/en-US.json | 3 +++ .../widgets/settings/backup_settings/backup_settings.dart | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 0075f65de0..d588507a07 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -154,6 +154,9 @@ "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", + "check_corrupt_asset_backup_button": "Perform check", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 2cecba6c4b..6c681e01df 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -54,7 +54,7 @@ class BackupSettings extends HookConsumerWidget { if (Platform.isAndroid && isAdvancedTroubleshooting.value) SettingsButtonListTile( icon: Icons.warning_rounded, - title: 'Check for corrupt asset backups', + title: 'check_corrupt_asset_backup'.tr(), subtitle: isCorruptCheckInProgress ? const Column( children: [ @@ -65,9 +65,9 @@ class BackupSettings extends HookConsumerWidget { ) : null, subtileText: !isCorruptCheckInProgress - ? 'Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.' + ? 'check_corrupt_asset_backup_description'.tr() : null, - buttonText: 'Perform check', + buttonText: 'check_corrupt_asset_backup_button'.tr(), onButtonTap: !isCorruptCheckInProgress ? () => ref .read(backupVerificationProvider.notifier) From 52247c36505405c8cd2bf5e5ab2373be4ea24a39 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:28:50 -0500 Subject: [PATCH 505/599] fix(server): always set transcoding device, prefer renderD* (#14455) always set device, prefer renderD* --- server/src/services/media.service.spec.ts | 63 +++++++----- server/src/utils/media.ts | 115 +++++++--------------- 2 files changed, 73 insertions(+), 105 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 5fd947e860..909b9d02e3 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1637,7 +1637,10 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ - inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), + inputOptions: expect.arrayContaining([ + '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', + '-filter_hw_device hw', + ]), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, '-c:a copy', @@ -1696,7 +1699,10 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ - inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), + inputOptions: expect.arrayContaining([ + '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', + '-filter_hw_device hw', + ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, }), @@ -1713,7 +1719,10 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ - inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), + inputOptions: expect.arrayContaining([ + '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', + '-filter_hw_device hw', + ]), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, }), @@ -1730,6 +1739,26 @@ describe(MediaService.name, () => { expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.'); }); + it('should prefer higher index renderD* device for qsv', async () => { + storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.arrayContaining([ + '-init_hw_device qsv=hw,child_device=/dev/dri/renderD129', + '-filter_hw_device hw', + ]), + outputOptions: expect.arrayContaining([`-c:v h264_qsv`]), + twoPass: false, + }), + ); + }); + it('should use hardware decoding for qsv if enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); @@ -1750,6 +1779,7 @@ describe(MediaService.name, () => { '-async_depth 4', '-noautorotate', '-threads 1', + '-qsv_device /dev/dri/renderD128', ]), outputOptions: expect.arrayContaining([ expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), @@ -1939,8 +1969,8 @@ describe(MediaService.name, () => { ); }); - it('should prefer gpu for vaapi if available', async () => { - storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); + it('should prefer higher index renderD* device for vaapi', async () => { + storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1950,27 +1980,7 @@ describe(MediaService.name, () => { 'upload/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ inputOptions: expect.arrayContaining([ - '-init_hw_device vaapi=accel:/dev/dri/card1', - '-filter_hw_device accel', - ]), - outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), - twoPass: false, - }), - ); - }); - - it('should prefer higher index gpu node', async () => { - storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']); - mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - expect.objectContaining({ - inputOptions: expect.arrayContaining([ - '-init_hw_device vaapi=accel:/dev/dri/renderD130', + '-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), @@ -2020,6 +2030,7 @@ describe(MediaService.name, () => { '-hwaccel_output_format vaapi', '-noautorotate', '-threads 1', + '-hwaccel_device /dev/dri/renderD128', ]), outputOptions: expect.arrayContaining([ expect.stringContaining('scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12'), diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index c7df4d27a7..226f95b4bb 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -322,14 +322,14 @@ export class BaseConfig implements VideoCodecSWConfig { } export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { - protected devices: string[]; + protected device: string; constructor( protected config: SystemConfigFFmpegDto, devices: string[] = [], ) { super(config); - this.devices = this.validateDevices(devices); + this.device = this.getDevice(devices); } getSupportedCodecs() { @@ -337,18 +337,29 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { } validateDevices(devices: string[]) { - return devices - .filter((device) => device.startsWith('renderD') || device.startsWith('card')) - .sort((a, b) => { - // order GPU devices first - if (a.startsWith('card') && b.startsWith('renderD')) { - return -1; - } - if (a.startsWith('renderD') && b.startsWith('card')) { - return 1; - } - return -a.localeCompare(b); - }); + if (devices.length === 0) { + throw new Error('No /dev/dri devices found. If using Docker, make sure at least one /dev/dri device is mounted'); + } + + return devices.filter(function (device) { + return device.startsWith('renderD') || device.startsWith('card'); + }); + } + + getDevice(devices: string[]) { + if (this.config.preferredHwDevice === 'auto') { + // eslint-disable-next-line unicorn/no-array-reduce + return `/dev/dri/${this.validateDevices(devices).reduce(function (a, b) { + return a.localeCompare(b) < 0 ? b : a; + })}`; + } + + const deviceName = this.config.preferredHwDevice.replace('/dev/dri/', ''); + if (!devices.includes(deviceName)) { + throw new Error(`Device '${deviceName}' does not exist. If using Docker, make sure this device is mounted`); + } + + return `/dev/dri/${deviceName}`; } getVideoCodec(): string { @@ -361,20 +372,6 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { } return this.config.gopSize; } - - getPreferredHardwareDevice(): string | undefined { - const device = this.config.preferredHwDevice; - if (device === 'auto') { - return; - } - - const deviceName = device.replace('/dev/dri/', ''); - if (!this.devices.includes(deviceName)) { - throw new Error(`Device '${device}' does not exist`); - } - - return `/dev/dri/${deviceName}`; - } } export class ThumbnailConfig extends BaseConfig { @@ -513,12 +510,16 @@ export class AV1Config extends BaseConfig { } export class NvencSwDecodeConfig extends BaseHWConfig { + getDevice() { + return '0'; + } + getSupportedCodecs() { return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; } getBaseInputOptions() { - return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']; + return [`-init_hw_device cuda=cuda:${this.device}`, '-filter_hw_device cuda']; } getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { @@ -641,17 +642,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig { export class QsvSwDecodeConfig extends BaseHWConfig { getBaseInputOptions() { - if (this.devices.length === 0) { - throw new Error('No QSV device found'); - } - - let qsvString = ''; - const hwDevice = this.getPreferredHardwareDevice(); - if (hwDevice) { - qsvString = `,child_device=${hwDevice}`; - } - - return [`-init_hw_device qsv=hw${qsvString}`, '-filter_hw_device hw']; + return [`-init_hw_device qsv=hw,child_device=${this.device}`, '-filter_hw_device hw']; } getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { @@ -721,23 +712,14 @@ export class QsvSwDecodeConfig extends BaseHWConfig { export class QsvHwDecodeConfig extends QsvSwDecodeConfig { getBaseInputOptions() { - if (this.devices.length === 0) { - throw new Error('No QSV device found'); - } - - const options = [ + return [ '-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', '-noautorotate', + `-qsv_device ${this.device}`, ...this.getInputThreadOptions(), ]; - const hwDevice = this.getPreferredHardwareDevice(); - if (hwDevice) { - options.push(`-qsv_device ${hwDevice}`); - } - - return options; } getFilterOptions(videoStream: VideoStreamInfo) { @@ -789,16 +771,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { export class VaapiSwDecodeConfig extends BaseHWConfig { getBaseInputOptions() { - if (this.devices.length === 0) { - throw new Error('No VAAPI device found'); - } - - let hwDevice = this.getPreferredHardwareDevice(); - if (!hwDevice) { - hwDevice = `/dev/dri/${this.devices[0]}`; - } - - return [`-init_hw_device vaapi=accel:${hwDevice}`, '-filter_hw_device accel']; + return [`-init_hw_device vaapi=accel:${this.device}`, '-filter_hw_device accel']; } getFilterOptions(videoStream: VideoStreamInfo) { @@ -856,22 +829,13 @@ export class VaapiSwDecodeConfig extends BaseHWConfig { export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig { getBaseInputOptions() { - if (this.devices.length === 0) { - throw new Error('No VAAPI device found'); - } - - const options = [ + return [ '-hwaccel vaapi', '-hwaccel_output_format vaapi', '-noautorotate', + `-hwaccel_device ${this.device}`, ...this.getInputThreadOptions(), ]; - const hwDevice = this.getPreferredHardwareDevice(); - if (hwDevice) { - options.push(`-hwaccel_device ${hwDevice}`); - } - - return options; } getFilterOptions(videoStream: VideoStreamInfo) { @@ -934,9 +898,6 @@ export class RkmppSwDecodeConfig extends BaseHWConfig { } getBaseInputOptions(): string[] { - if (this.devices.length === 0) { - throw new Error('No RKMPP device found'); - } return []; } @@ -987,10 +948,6 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { } getBaseInputOptions() { - if (this.devices.length === 0) { - throw new Error('No RKMPP device found'); - } - return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate']; } From ba9b9353bc6fb33ea961c5f6c7e1367eb6dd6d1e Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:04:42 -0500 Subject: [PATCH 506/599] fix(server): show people without thumbnails (#14460) * show people without thumbnails * redundant clause * updated sql --- server/src/queries/person.repository.sql | 8 ++------ server/src/repositories/person.repository.ts | 7 ++----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 5616559d7d..a7e683fca1 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -20,13 +20,12 @@ SELECT "person"."isHidden" AS "person_isHidden" FROM "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" + INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "person"."ownerId" = $1 AND "asset"."isArchived" = false - AND "person"."thumbnailPath" != '' AND "person"."isHidden" = false GROUP BY "person"."id" @@ -257,15 +256,12 @@ SELECT ) AS "hidden" FROM "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" + INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "person"."ownerId" = $1 AND "asset"."isArchived" = false - AND "person"."thumbnailPath" != '' -HAVING - COUNT("face"."assetId") != 0 -- PersonRepository.getFacesByIds SELECT diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 56116d7b3b..81958d269d 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -86,7 +86,7 @@ export class PersonRepository implements IPersonRepository { getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated { const queryBuilder = this.personRepository .createQueryBuilder('person') - .leftJoin('person.faces', 'face') + .innerJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) .innerJoin('face.asset', 'asset') .andWhere('asset.isArchived = false') @@ -95,7 +95,6 @@ export class PersonRepository implements IPersonRepository { .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') .addOrderBy('person.createdAt') - .andWhere("person.thumbnailPath != ''") .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .groupBy('person.id'); if (!options?.withHidden) { @@ -232,14 +231,12 @@ export class PersonRepository implements IPersonRepository { async getNumberOfPeople(userId: string): Promise { const items = await this.personRepository .createQueryBuilder('person') - .leftJoin('person.faces', 'face') + .innerJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) .innerJoin('face.asset', 'asset') .andWhere('asset.isArchived = false') - .andWhere("person.thumbnailPath != ''") .select('COUNT(DISTINCT(person.id))', 'total') .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') - .having('COUNT(face.assetId) != 0') .getRawOne(); if (items == undefined) { From 411878c0aac9e7094cb404e7eb8907d5d85630a6 Mon Sep 17 00:00:00 2001 From: Alessandro Piccin <117726828+alessandrv@users.noreply.github.com> Date: Wed, 4 Dec 2024 20:53:55 +0100 Subject: [PATCH 507/599] fix(mobile): album most recent sorting on mobile (#13766) * Fix album most recent sorting on mobile * fix: format * fix: format --------- Co-authored-by: Alex --- .../album/album_sort_by_options.provider.dart | 19 +++++--- mobile/test/fixtures/album.stub.dart | 45 +++++++++++++++++++ .../album_sort_by_options_provider_test.dart | 40 ++++++++++++----- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/mobile/lib/providers/album/album_sort_by_options.provider.dart b/mobile/lib/providers/album/album_sort_by_options.provider.dart index 216688ee15..cafde37253 100644 --- a/mobile/lib/providers/album/album_sort_by_options.provider.dart +++ b/mobile/lib/providers/album/album_sort_by_options.provider.dart @@ -39,12 +39,21 @@ class _AlbumSortHandlers { static const AlbumSortFn mostRecent = _sortByMostRecent; static List _sortByMostRecent(List albums, bool isReverse) { final sorted = albums.sorted((a, b) { - if (a.endDate != null && b.endDate != null) { - return a.endDate!.compareTo(b.endDate!); + if (a.endDate == null && b.endDate == null) { + return 0; } - if (a.endDate == null) return 1; - if (b.endDate == null) return -1; - return 0; + + if (a.endDate == null) { + // Put nulls at the end for recent sorting + return 1; + } + + if (b.endDate == null) { + return -1; + } + + // Sort by descending recent date + return b.endDate!.compareTo(a.endDate!); }); return (isReverse ? sorted.reversed : sorted).toList(); } diff --git a/mobile/test/fixtures/album.stub.dart b/mobile/test/fixtures/album.stub.dart index 4fa0dac1d2..e820f193d5 100644 --- a/mobile/test/fixtures/album.stub.dart +++ b/mobile/test/fixtures/album.stub.dart @@ -54,4 +54,49 @@ final class AlbumStub { ..assets.addAll([AssetStub.image1, AssetStub.image2]) ..activityEnabled = true ..owner.value = UserStub.admin; + + static final create2020end2020Album = Album( + name: "create2020update2020Album", + localId: "create2020update2020Album-local", + remoteId: "create2020update2020Album-remote", + createdAt: DateTime(2020), + modifiedAt: DateTime(2020), + shared: false, + activityEnabled: false, + startDate: DateTime(2020), + endDate: DateTime(2020), + ); + static final create2020end2022Album = Album( + name: "create2020update2021Album", + localId: "create2020update2021Album-local", + remoteId: "create2020update2021Album-remote", + createdAt: DateTime(2020), + modifiedAt: DateTime(2022), + shared: false, + activityEnabled: false, + startDate: DateTime(2020), + endDate: DateTime(2022), + ); + static final create2020end2024Album = Album( + name: "create2020update2022Album", + localId: "create2020update2022Album-local", + remoteId: "create2020update2022Album-remote", + createdAt: DateTime(2020), + modifiedAt: DateTime(2024), + shared: false, + activityEnabled: false, + startDate: DateTime(2020), + endDate: DateTime(2024), + ); + static final create2020end2026Album = Album( + name: "create2020update2023Album", + localId: "create2020update2023Album-local", + remoteId: "create2020update2023Album-remote", + createdAt: DateTime(2020), + modifiedAt: DateTime(2026), + shared: false, + activityEnabled: false, + startDate: DateTime(2020), + endDate: DateTime(2026), + ); } diff --git a/mobile/test/modules/album/album_sort_by_options_provider_test.dart b/mobile/test/modules/album/album_sort_by_options_provider_test.dart index 84a7e6e9b8..bfb61ef402 100644 --- a/mobile/test/modules/album/album_sort_by_options_provider_test.dart +++ b/mobile/test/modules/album/album_sort_by_options_provider_test.dart @@ -147,24 +147,40 @@ void main() { group("Album sort - Most Recent", () { const mostRecent = AlbumSortMode.mostRecent; - test("Most Recent - ASC", () { - final sorted = mostRecent.sortFn(albums, false); + test("Most Recent - DESC", () { + final sorted = mostRecent.sortFn( + [ + AlbumStub.create2020end2020Album, + AlbumStub.create2020end2022Album, + AlbumStub.create2020end2024Album, + AlbumStub.create2020end2026Album, + ], + false, + ); final sortedList = [ - AlbumStub.sharedWithUser, - AlbumStub.twoAsset, - AlbumStub.oneAsset, - AlbumStub.emptyAlbum, + AlbumStub.create2020end2026Album, + AlbumStub.create2020end2024Album, + AlbumStub.create2020end2022Album, + AlbumStub.create2020end2020Album, ]; expect(sorted, orderedEquals(sortedList)); }); - test("Most Recent - DESC", () { - final sorted = mostRecent.sortFn(albums, true); + test("Most Recent - ASC", () { + final sorted = mostRecent.sortFn( + [ + AlbumStub.create2020end2020Album, + AlbumStub.create2020end2022Album, + AlbumStub.create2020end2024Album, + AlbumStub.create2020end2026Album, + ], + true, + ); final sortedList = [ - AlbumStub.emptyAlbum, - AlbumStub.oneAsset, - AlbumStub.twoAsset, - AlbumStub.sharedWithUser, + AlbumStub.create2020end2020Album, + AlbumStub.create2020end2022Album, + AlbumStub.create2020end2024Album, + AlbumStub.create2020end2026Album, ]; expect(sorted, orderedEquals(sortedList)); }); From 4bf1b84cc2b2a3f796dd9bc34fe507ecb512dc71 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:17:47 -0500 Subject: [PATCH 508/599] feat(ml): support multiple urls (#14347) * support multiple url * update api * styling unnecessary `?.` * update docs, make new url field go first add load balancing section * update tests doc formatting wording wording linting * small styling * `url` -> `urls` * fix tests * update docs * make docusaurus happy --------- Co-authored-by: Alex --- docs/docs/guides/remote-machine-learning.md | 42 +++++++++++---- docs/docs/install/config-file.md | 2 +- i18n/en.json | 4 +- .../system_config_machine_learning_dto.dart | 34 +++++++++--- open-api/immich-openapi-specs.json | 12 ++++- open-api/typescript-sdk/src/fetch-client.ts | 4 +- server/src/config.ts | 4 +- server/src/cores/storage.core.spec.ts | 2 + server/src/dtos/system-config.dto.ts | 15 ++++-- .../interfaces/machine-learning.interface.ts | 6 +-- ...39482860-RenameMachineLearningUrlToUrls.ts | 19 +++++++ server/src/repositories/event.repository.ts | 2 +- .../machine-learning.repository.ts | 54 +++++++++++-------- server/src/services/person.service.spec.ts | 2 +- server/src/services/person.service.ts | 2 +- server/src/services/search.service.ts | 2 +- .../src/services/smart-info.service.spec.ts | 4 +- server/src/services/smart-info.service.ts | 2 +- .../services/system-config.service.spec.ts | 6 +-- .../machine-learning-settings.svelte | 48 +++++++++++++---- .../buttons/circle-icon-button.svelte | 3 +- .../settings/setting-input-field.svelte | 6 ++- 22 files changed, 202 insertions(+), 73 deletions(-) create mode 100644 server/src/migrations/1733339482860-RenameMachineLearningUrlToUrls.ts diff --git a/docs/docs/guides/remote-machine-learning.md b/docs/docs/guides/remote-machine-learning.md index 4dbb72a408..1abf7d4e54 100644 --- a/docs/docs/guides/remote-machine-learning.md +++ b/docs/docs/guides/remote-machine-learning.md @@ -1,18 +1,20 @@ # Remote Machine Learning -To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine-learning container on a more powerful system (e.g. your laptop or desktop computer): - -- Set the URL in Machine Learning Settings on the Admin Settings page to point to the designated ML system, e.g. `http://workstation:3003`. -- Copy the following `docker-compose.yml` to your ML system. - - If using [hardware acceleration](/docs/features/ml-hardware-acceleration), the [hwaccel.ml.yml](https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml) file also needs to be added -- Start the container by running `docker compose up -d`. +To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine learning container on a more powerful system, such as your laptop or desktop computer. The server container will send requests containing the image preview to the remote machine learning container for processing. The machine learning container does not persist this data or associate it with a particular user. :::info -Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server. +Smart Search and Face Detection will use this feature, but Facial Recognition will not. This is because Facial Recognition uses the _outputs_ of these models that have already been saved to the database. As such, its processing is between the server container and the database. ::: :::danger -When using remote machine learning, the thumbnails are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud. +Image previews are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud. Additionally, as an internal service, the machine learning container has no security measures whatsoever. Please be mindful of where it's deployed and who can access it. +::: + +1. Ensure the remote server has Docker installed +2. Copy the following `docker-compose.yml` to the remote server + +:::info +If using hardware acceleration, the [hwaccel.ml.yml](https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml) file also needs to be added and the `docker-compose.yml` needs to be configured as described in the [hardware acceleration documentation](/docs/features/ml-hardware-acceleration) ::: ```yaml @@ -37,8 +39,26 @@ volumes: model-cache: ``` -Please note that version mismatches between both hosts may cause instabilities and bugs, so make sure to always perform updates together. +3. Start the remote machine learning container by running `docker compose up -d` -:::caution -As an internal service, the machine learning container has no security measures whatsoever. Please be mindful of where it's deployed and who can access it. +:::info +Version mismatches between both hosts may cause bugs and instability, so remember to update this container as well when updating the local Immich instance. +::: + +4. Navigate to the [Machine Learning Settings](https://my.immich.app/admin/system-settings?isOpen=machine-learning) +5. Click _Add URL_ +6. Fill the new field with the URL to the remote machine learning container, e.g. `http://ip:port` + +## Forcing remote processing + +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. + +## Load balancing + +While several URLs can be provided in the settings, they are tried sequentially; there is no attempt to distribute load across multiple containers. It is recommended to use a dedicated load balancer for such use-cases and specify it as the only URL. Among other things, it may enable the use of different APIs on the same server by running multiple containers with different configurations. For example, one might run an OpenVINO container in addition to a CUDA container, or run a standard release container to maximize both CPU and GPU utilization. + +:::tip +The machine learning container can be shared among several Immich instances regardless of the models a particular instance uses. However, using different models will lead to higher peak memory usage. ::: diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 9d86b8dad7..d3d7133254 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -83,7 +83,7 @@ The default configuration looks like this: }, "machineLearning": { "enabled": true, - "url": "http://immich-machine-learning:3003", + "url": ["http://immich-machine-learning:3003"], "clip": { "enabled": true, "modelName": "ViT-B-32__openai" diff --git a/i18n/en.json b/i18n/en.json index 277db70a23..907f5df182 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -25,6 +25,7 @@ "add_to": "Add to...", "add_to_album": "Add to album", "add_to_shared_album": "Add to shared album", + "add_url": "Add URL", "added_to_archive": "Added to archive", "added_to_favorites": "Added to favorites", "added_to_favorites_count": "Added {count, number} to favorites", @@ -132,7 +133,7 @@ "machine_learning_smart_search_description": "Search for images semantically using CLIP embeddings", "machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", - "machine_learning_url_description": "URL of the machine learning server", + "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last.", "manage_concurrency": "Manage Concurrency", "manage_log_settings": "Manage log settings", "map_dark_style": "Dark style", @@ -1045,6 +1046,7 @@ "remove_from_album": "Remove from album", "remove_from_favorites": "Remove from favorites", "remove_from_shared_link": "Remove from shared link", + "remove_url": "Remove URL", "remove_user": "Remove user", "removed_api_key": "Removed API Key: {name}", "removed_from_archive": "Removed from archive", diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index d665f0bfa5..a4a9ca7d82 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -17,7 +17,8 @@ class SystemConfigMachineLearningDto { required this.duplicateDetection, required this.enabled, required this.facialRecognition, - required this.url, + this.url, + this.urls = const [], }); CLIPConfig clip; @@ -28,7 +29,16 @@ class SystemConfigMachineLearningDto { FacialRecognitionConfig facialRecognition; - String url; + /// This property was deprecated in v1.122.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? url; + + List urls; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto && @@ -36,7 +46,8 @@ class SystemConfigMachineLearningDto { other.duplicateDetection == duplicateDetection && other.enabled == enabled && other.facialRecognition == facialRecognition && - other.url == url; + other.url == url && + _deepEquality.equals(other.urls, urls); @override int get hashCode => @@ -45,10 +56,11 @@ class SystemConfigMachineLearningDto { (duplicateDetection.hashCode) + (enabled.hashCode) + (facialRecognition.hashCode) + - (url.hashCode); + (url == null ? 0 : url!.hashCode) + + (urls.hashCode); @override - String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url]'; + String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url, urls=$urls]'; Map toJson() { final json = {}; @@ -56,7 +68,12 @@ class SystemConfigMachineLearningDto { json[r'duplicateDetection'] = this.duplicateDetection; json[r'enabled'] = this.enabled; json[r'facialRecognition'] = this.facialRecognition; + if (this.url != null) { json[r'url'] = this.url; + } else { + // json[r'url'] = null; + } + json[r'urls'] = this.urls; return json; } @@ -73,7 +90,10 @@ class SystemConfigMachineLearningDto { duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!, enabled: mapValueOfType(json, r'enabled')!, facialRecognition: FacialRecognitionConfig.fromJson(json[r'facialRecognition'])!, - url: mapValueOfType(json, r'url')!, + url: mapValueOfType(json, r'url'), + urls: json[r'urls'] is Iterable + ? (json[r'urls'] as Iterable).cast().toList(growable: false) + : const [], ); } return null; @@ -125,7 +145,7 @@ class SystemConfigMachineLearningDto { 'duplicateDetection', 'enabled', 'facialRecognition', - 'url', + 'urls', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 20ebe607a4..bc32a32e04 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11857,7 +11857,17 @@ "$ref": "#/components/schemas/FacialRecognitionConfig" }, "url": { + "deprecated": true, + "description": "This property was deprecated in v1.122.0", "type": "string" + }, + "urls": { + "items": { + "format": "uri", + "type": "string" + }, + "minItems": 1, + "type": "array" } }, "required": [ @@ -11865,7 +11875,7 @@ "duplicateDetection", "enabled", "facialRecognition", - "url" + "urls" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9b79816091..d786139ab5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1178,7 +1178,9 @@ export type SystemConfigMachineLearningDto = { duplicateDetection: DuplicateDetectionConfig; enabled: boolean; facialRecognition: FacialRecognitionConfig; - url: string; + /** This property was deprecated in v1.122.0 */ + url?: string; + urls: string[]; }; export type SystemConfigMapDto = { darkStyle: string; diff --git a/server/src/config.ts b/server/src/config.ts index f5e78ab01b..dd850e063f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -52,7 +52,7 @@ export interface SystemConfig { }; machineLearning: { enabled: boolean; - url: string; + urls: string[]; clip: { enabled: boolean; modelName: string; @@ -206,7 +206,7 @@ export const defaults = Object.freeze({ }, machineLearning: { enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', - url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003', + urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], clip: { enabled: true, modelName: 'ViT-B-32__openai', diff --git a/server/src/cores/storage.core.spec.ts b/server/src/cores/storage.core.spec.ts index 6ff6ca61bf..a663673306 100644 --- a/server/src/cores/storage.core.spec.ts +++ b/server/src/cores/storage.core.spec.ts @@ -3,6 +3,8 @@ import { vitest } from 'vitest'; vitest.mock('src/constants', () => ({ APP_MEDIA_LOCATION: '/photos', + ADDED_IN_PREFIX: 'This property was added in ', + DEPRECATED_IN_PREFIX: 'This property was deprecated in ', })); describe('StorageCore', () => { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 8d79fecb22..894f4c7948 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Exclude, Transform, Type } from 'class-transformer'; import { + ArrayMinSize, IsBoolean, IsEnum, IsInt, @@ -16,6 +17,7 @@ import { ValidateNested, } from 'class-validator'; import { SystemConfig } from 'src/config'; +import { PropertyLifecycle } from 'src/decorators'; import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { AudioCodec, @@ -269,9 +271,16 @@ class SystemConfigMachineLearningDto { @ValidateBoolean() enabled!: boolean; - @IsUrl({ require_tld: false, allow_underscores: true }) + @PropertyLifecycle({ deprecatedAt: 'v1.122.0' }) + @Exclude() + url?: string; + + @IsUrl({ require_tld: false, allow_underscores: true }, { each: true }) + @ArrayMinSize(1) + @Transform(({ obj, value }) => (obj.url ? [obj.url] : value)) @ValidateIf((dto) => dto.enabled) - url!: string; + @ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 }) + urls!: string[]; @Type(() => CLIPConfig) @ValidateNested() diff --git a/server/src/interfaces/machine-learning.interface.ts b/server/src/interfaces/machine-learning.interface.ts index 5342030c8f..372aa0c7cd 100644 --- a/server/src/interfaces/machine-learning.interface.ts +++ b/server/src/interfaces/machine-learning.interface.ts @@ -51,7 +51,7 @@ export type DetectedFaces = { faces: Face[] } & VisualResponse; export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; export interface IMachineLearningRepository { - encodeImage(url: string, imagePath: string, config: ModelOptions): Promise; - encodeText(url: string, text: string, config: ModelOptions): Promise; - detectFaces(url: string, imagePath: string, config: FaceDetectionOptions): Promise; + encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise; + encodeText(urls: string[], text: string, config: ModelOptions): Promise; + detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise; } diff --git a/server/src/migrations/1733339482860-RenameMachineLearningUrlToUrls.ts b/server/src/migrations/1733339482860-RenameMachineLearningUrlToUrls.ts new file mode 100644 index 0000000000..65bb02c8e2 --- /dev/null +++ b/server/src/migrations/1733339482860-RenameMachineLearningUrlToUrls.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameMachineLearningUrlToUrls1733339482860 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE system_metadata + SET value = jsonb_insert(value #- '{machineLearning,url}', '{machineLearning,urls}'::text[], jsonb_build_array(value->'machineLearning'->'url')) + WHERE key = 'system-config' AND value->'machineLearning'->'url' IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE system_metadata + SET value = jsonb_insert(value #- '{machineLearning,urls}', '{machineLearning,url}'::text[], to_jsonb(value->'machineLearning'->'urls'->>0)) + WHERE key = 'system-config' AND value->'machineLearning'->'urls' IS NOT NULL AND jsonb_array_length(value->'machineLearning'->'urls') >= 1 + `); + } +} diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 96df72e43f..7de8defe6e 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -155,7 +155,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.emitHandlers[event].push(item); } - async emit(event: T, ...args: ArgsOf): Promise { + emit(event: T, ...args: ArgsOf): Promise { return this.onEvent({ name: event, args, server: false }); } diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 74b17ca6a7..56cdf30a1e 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { readFile } from 'node:fs/promises'; import { CLIPConfig } from 'src/dtos/model-config.dto'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ClipTextualResponse, ClipVisualResponse, @@ -13,33 +14,42 @@ import { ModelType, } from 'src/interfaces/machine-learning.interface'; -const errorPrefix = 'Machine learning request'; - @Injectable() export class MachineLearningRepository implements IMachineLearningRepository { - private async predict(url: string, payload: ModelPayload, config: MachineLearningRequest): Promise { - const formData = await this.getFormData(payload, config); - - const res = await fetch(new URL('/predict', url), { method: 'POST', body: formData }).catch( - (error: Error | any) => { - throw new Error(`${errorPrefix} to "${url}" failed with ${error?.cause || error}`); - }, - ); - - if (res.status >= 400) { - throw new Error(`${errorPrefix} '${JSON.stringify(config)}' failed with status ${res.status}: ${res.statusText}`); - } - return res.json(); + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(MachineLearningRepository.name); } - async detectFaces(url: string, imagePath: string, { modelName, minScore }: FaceDetectionOptions) { + private async predict(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise { + const formData = await this.getFormData(payload, config); + for (const url of urls) { + try { + const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); + if (response.ok) { + return response.json(); + } + + this.logger.warn( + `Machine learning request to "${url}" failed with status ${response.status}: ${response.statusText}`, + ); + } catch (error: Error | unknown) { + this.logger.warn( + `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, + ); + } + } + + throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); + } + + async detectFaces(urls: string[], imagePath: string, { modelName, minScore }: FaceDetectionOptions) { const request = { [ModelTask.FACIAL_RECOGNITION]: { [ModelType.DETECTION]: { modelName, options: { minScore } }, [ModelType.RECOGNITION]: { modelName }, }, }; - const response = await this.predict(url, { imagePath }, request); + const response = await this.predict(urls, { imagePath }, request); return { imageHeight: response.imageHeight, imageWidth: response.imageWidth, @@ -47,15 +57,15 @@ export class MachineLearningRepository implements IMachineLearningRepository { }; } - async encodeImage(url: string, imagePath: string, { modelName }: CLIPConfig) { + async encodeImage(urls: string[], imagePath: string, { modelName }: CLIPConfig) { const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; - const response = await this.predict(url, { imagePath }, request); + const response = await this.predict(urls, { imagePath }, request); return response[ModelTask.SEARCH]; } - async encodeText(url: string, text: string, { modelName }: CLIPConfig) { + async encodeText(urls: string[], text: string, { modelName }: CLIPConfig) { const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName } } }; - const response = await this.predict(url, { text }, request); + const response = await this.predict(urls, { text }, request); return response[ModelTask.SEARCH]; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index da4656be02..3b749c0ab6 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -717,7 +717,7 @@ describe(PersonService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( - 'http://immich-machine-learning:3003', + ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 5b6e721eab..79e82bb742 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -297,7 +297,7 @@ export class PersonService extends BaseService { } const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( - machineLearning.url, + machineLearning.urls, previewFile.path, machineLearning.facialRecognition, ); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 04d3addb63..bf5bf9e311 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -86,7 +86,7 @@ export class SearchService extends BaseService { const userIds = await this.getUserIdsToSearch(auth); const embedding = await this.machineLearningRepository.encodeText( - machineLearning.url, + machineLearning.urls, dto.query, machineLearning.clip, ); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 250f9326f9..0b0ee6b20f 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -289,7 +289,7 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( - 'http://immich-machine-learning:3003', + ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); @@ -322,7 +322,7 @@ describe(SmartInfoService.name, () => { expect(databaseMock.wait).toHaveBeenCalledWith(512); expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( - 'http://immich-machine-learning:3003', + ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 9122a48658..8fef961fe1 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -122,7 +122,7 @@ export class SmartInfoService extends BaseService { } const embedding = await this.machineLearningRepository.encodeImage( - machineLearning.url, + machineLearning.urls, previewFile.path, machineLearning.clip, ); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 4d5a29e8a8..2550c15de2 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -85,7 +85,7 @@ const updatedConfig = Object.freeze({ }, machineLearning: { enabled: true, - url: 'http://immich-machine-learning:3003', + urls: ['http://immich-machine-learning:3003'], clip: { enabled: true, modelName: 'ViT-B-32__openai', @@ -330,11 +330,11 @@ describe(SystemConfigService.name, () => { it('should allow underscores in the machine learning url', async () => { configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); - const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; + const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getSystemConfig(); - expect(config.machineLearning.url).toEqual('immich_machine_learning'); + expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); }); const externalDomainTests = [ diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 13678a31c1..90131d7238 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -12,6 +12,9 @@ import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; import { SettingInputFieldType } from '$lib/constants'; + import Button from '$lib/components/elements/buttons/button.svelte'; + import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { mdiMinusCircle } from '@mdi/js'; interface Props { savedConfig: SystemConfigDto; @@ -42,15 +45,42 @@
    - +
    + {#each config.machineLearning.urls as _, i} + {#snippet removeButton()} + {#if config.machineLearning.urls.length > 1} + config.machineLearning.urls.splice(i, 1)} + icon={mdiMinusCircle} + /> + {/if} + {/snippet} + + + {/each} +
    + +
    - export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert'; + export type Color = 'transparent' | 'light' | 'dark' | 'red' | 'gray' | 'primary' | 'opaque' | 'alert'; export type Padding = '1' | '2' | '3'; @@ -64,6 +64,7 @@ transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg', opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white', light: 'bg-white hover:bg-[#d3d3d3]', + red: 'text-red-400 hover:bg-[#d3d3d3]', dark: 'bg-[#202123] hover:bg-[#d3d3d3]', alert: 'text-[#ff0000] hover:text-white', gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black', diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte index 1463cc4840..a04f521773 100644 --- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte +++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte @@ -22,6 +22,7 @@ autofocus?: boolean; passwordAutocomplete?: AutoFill; descriptionSnippet?: Snippet; + trailingSnippet?: Snippet; } let { @@ -39,6 +40,7 @@ autofocus = false, passwordAutocomplete = 'current-password', descriptionSnippet, + trailingSnippet, }: Props = $props(); let input: HTMLInputElement | undefined = $state(); @@ -68,7 +70,7 @@
    -
    +
    {#if required}
    *
    @@ -132,6 +134,8 @@ {disabled} {title} /> + + {@render trailingSnippet?.()}
    {:else} Date: Wed, 4 Dec 2024 21:26:02 +0100 Subject: [PATCH 509/599] feat: Notification Email Templates (#13940) --- .../administration/email-notification.mdx | 6 + .../img/user-notifications-templates.png | Bin 0 -> 199469 bytes docs/docs/administration/system-settings.md | 4 + i18n/en.json | 12 +- i18n/nl.json | 10 ++ mobile/openapi/README.md | 5 + mobile/openapi/lib/api.dart | 4 + mobile/openapi/lib/api/notifications_api.dart | 52 +++++++ mobile/openapi/lib/api_client.dart | 8 ++ .../openapi/lib/model/system_config_dto.dart | 10 +- .../system_config_template_emails_dto.dart | 115 +++++++++++++++ .../model/system_config_templates_dto.dart | 99 +++++++++++++ mobile/openapi/lib/model/template_dto.dart | 99 +++++++++++++ .../lib/model/template_response_dto.dart | 107 ++++++++++++++ open-api/immich-openapi-specs.json | 111 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 29 ++++ server/src/config.ts | 14 ++ .../controllers/notification.controller.ts | 16 ++- server/src/dtos/notification.dto.ts | 10 ++ server/src/dtos/system-config.dto.ts | 23 +++ server/src/emails/album-invite.email.tsx | 84 +++++++---- server/src/emails/album-update.email.tsx | 93 +++++++++---- server/src/emails/welcome.email.tsx | 74 ++++++---- .../src/interfaces/notification.interface.ts | 5 + .../notification.repository.spec.ts | 4 + .../repositories/notification.repository.ts | 10 +- server/src/services/notification.service.ts | 76 +++++++++- .../services/system-config.service.spec.ts | 7 + server/src/utils/replace-template-tags.ts | 5 + web/package-lock.json | 2 +- .../notification-settings.svelte | 16 ++- .../template-settings.svelte | 131 ++++++++++++++++++ 32 files changed, 1136 insertions(+), 105 deletions(-) create mode 100644 docs/docs/administration/img/user-notifications-templates.png create mode 100644 mobile/openapi/lib/model/system_config_template_emails_dto.dart create mode 100644 mobile/openapi/lib/model/system_config_templates_dto.dart create mode 100644 mobile/openapi/lib/model/template_dto.dart create mode 100644 mobile/openapi/lib/model/template_response_dto.dart create mode 100644 server/src/utils/replace-template-tags.ts create mode 100644 web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 93b1051053..2f244f3352 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -19,3 +19,9 @@ You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events: + +## Notification templates + +You can override the default notification text with custom templates in HTML format. You can use tags to show dynamic tags in your templates. + + diff --git a/docs/docs/administration/img/user-notifications-templates.png b/docs/docs/administration/img/user-notifications-templates.png new file mode 100644 index 0000000000000000000000000000000000000000..150d39b7a6a2a0e499bd7d5e793f07179495afd7 GIT binary patch literal 199469 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

    6~#=|P$bc} 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 is the user's Storage Label", "system_settings": "System Settings", "tag_cleanup_job": "Tag cleanup", + "template_email_preview": "Preview", + "template_email_settings": "Email Templates", + "template_email_settings_description": "Manage custom email notification templates", + "template_email_welcome": "Welcome email template", + "template_email_invite_album": "Invite Album Template", + "template_email_update_album": "Update Album Template", + "template_settings": "Notification Templates", + "template_settings_description": "Manage custom templates for notifications.", + "template_email_if_empty": "If the template is empty, the default email will be used.", + "template_email_available_tags": "You can use the following variables in your template: {tags}", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -1325,4 +1335,4 @@ "zoom_image": "Zoom Image", "timeline": "Timeline", "total": "Total" -} +} \ No newline at end of file diff --git a/i18n/nl.json b/i18n/nl.json index ade7a50925..3420c5d105 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -247,6 +247,16 @@ "storage_template_user_label": "{label} is het opslaglabel van de gebruiker", "system_settings": "Systeeminstellingen", "tag_cleanup_job": "Tag opschoning", + "template_email_settings": "Email", + "template_email_settings_description": "Beheer aangepaste email melding sjablonen", + "template_email_preview": "Voorbeeld", + "template_email_welcome": "Welkom email sjabloon", + "template_email_invite_album": "Uitgenodigd in album sjabloon", + "template_email_update_album": "Update in album sjabloon", + "template_settings": "Melding sjablonen", + "template_settings_description": "Beheer aangepast sjablonen voor meldingen.", + "template_email_if_empty": "Wanneer het sjabloon leeg is, wordt de standaard mail gebruikt.", + "template_email_available_tags": "Je kan de volgende tags gebruiken in een template: {tags}", "theme_custom_css_settings": "Aangepaste CSS", "theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.", "theme_settings": "Thema instellingen", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7780935902..b97ff5411c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -144,6 +144,7 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | +*NotificationsApi* | [**getNotificationTemplate**](doc//NotificationsApi.md#getnotificationtemplate) | **POST** /notifications/templates/{name} | *NotificationsApi* | [**sendTestEmail**](doc//NotificationsApi.md#sendtestemail) | **POST** /notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | @@ -436,7 +437,9 @@ Class | Method | HTTP request | Description - [SystemConfigSmtpDto](doc//SystemConfigSmtpDto.md) - [SystemConfigSmtpTransportDto](doc//SystemConfigSmtpTransportDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) + - [SystemConfigTemplateEmailsDto](doc//SystemConfigTemplateEmailsDto.md) - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md) + - [SystemConfigTemplatesDto](doc//SystemConfigTemplatesDto.md) - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) @@ -448,6 +451,8 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TemplateDto](doc//TemplateDto.md) + - [TemplateResponseDto](doc//TemplateResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e1c343ad50..73eb02d89e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -250,7 +250,9 @@ part 'model/system_config_server_dto.dart'; part 'model/system_config_smtp_dto.dart'; part 'model/system_config_smtp_transport_dto.dart'; part 'model/system_config_storage_template_dto.dart'; +part 'model/system_config_template_emails_dto.dart'; part 'model/system_config_template_storage_option_dto.dart'; +part 'model/system_config_templates_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; @@ -262,6 +264,8 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/template_dto.dart'; +part 'model/template_response_dto.dart'; part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index 0681d58247..323fbcc3d6 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -16,6 +16,58 @@ class NotificationsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /notifications/templates/{name}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [TemplateDto] templateDto (required): + Future getNotificationTemplateWithHttpInfo(String name, TemplateDto templateDto,) async { + // ignore: prefer_const_declarations + final path = r'/notifications/templates/{name}' + .replaceAll('{name}', name); + + // ignore: prefer_final_locals + Object? postBody = templateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [TemplateDto] templateDto (required): + Future getNotificationTemplate(String name, TemplateDto templateDto,) async { + final response = await getNotificationTemplateWithHttpInfo(name, templateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TemplateResponseDto',) as TemplateResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /notifications/test-email' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index b71e6f45f7..a6f8d551da 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -554,8 +554,12 @@ class ApiClient { return SystemConfigSmtpTransportDto.fromJson(value); case 'SystemConfigStorageTemplateDto': return SystemConfigStorageTemplateDto.fromJson(value); + case 'SystemConfigTemplateEmailsDto': + return SystemConfigTemplateEmailsDto.fromJson(value); case 'SystemConfigTemplateStorageOptionDto': return SystemConfigTemplateStorageOptionDto.fromJson(value); + case 'SystemConfigTemplatesDto': + return SystemConfigTemplatesDto.fromJson(value); case 'SystemConfigThemeDto': return SystemConfigThemeDto.fromJson(value); case 'SystemConfigTrashDto': @@ -578,6 +582,10 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TemplateDto': + return TemplateDto.fromJson(value); + case 'TemplateResponseDto': + return TemplateResponseDto.fromJson(value); case 'TestEmailResponseDto': return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 4215953906..59d5f09fc9 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -29,6 +29,7 @@ class SystemConfigDto { required this.reverseGeocoding, required this.server, required this.storageTemplate, + required this.templates, required this.theme, required this.trash, required this.user, @@ -66,6 +67,8 @@ class SystemConfigDto { SystemConfigStorageTemplateDto storageTemplate; + SystemConfigTemplatesDto templates; + SystemConfigThemeDto theme; SystemConfigTrashDto trash; @@ -90,6 +93,7 @@ class SystemConfigDto { other.reverseGeocoding == reverseGeocoding && other.server == server && other.storageTemplate == storageTemplate && + other.templates == templates && other.theme == theme && other.trash == trash && other.user == user; @@ -113,12 +117,13 @@ class SystemConfigDto { (reverseGeocoding.hashCode) + (server.hashCode) + (storageTemplate.hashCode) + + (templates.hashCode) + (theme.hashCode) + (trash.hashCode) + (user.hashCode); @override - String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; + String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, templates=$templates, theme=$theme, trash=$trash, user=$user]'; Map toJson() { final json = {}; @@ -138,6 +143,7 @@ class SystemConfigDto { json[r'reverseGeocoding'] = this.reverseGeocoding; json[r'server'] = this.server; json[r'storageTemplate'] = this.storageTemplate; + json[r'templates'] = this.templates; json[r'theme'] = this.theme; json[r'trash'] = this.trash; json[r'user'] = this.user; @@ -169,6 +175,7 @@ class SystemConfigDto { reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!, server: SystemConfigServerDto.fromJson(json[r'server'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, + templates: SystemConfigTemplatesDto.fromJson(json[r'templates'])!, theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, user: SystemConfigUserDto.fromJson(json[r'user'])!, @@ -235,6 +242,7 @@ class SystemConfigDto { 'reverseGeocoding', 'server', 'storageTemplate', + 'templates', 'theme', 'trash', 'user', diff --git a/mobile/openapi/lib/model/system_config_template_emails_dto.dart b/mobile/openapi/lib/model/system_config_template_emails_dto.dart new file mode 100644 index 0000000000..9db85509f5 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_template_emails_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigTemplateEmailsDto { + /// Returns a new [SystemConfigTemplateEmailsDto] instance. + SystemConfigTemplateEmailsDto({ + required this.albumInviteTemplate, + required this.albumUpdateTemplate, + required this.welcomeTemplate, + }); + + String albumInviteTemplate; + + String albumUpdateTemplate; + + String welcomeTemplate; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplateEmailsDto && + other.albumInviteTemplate == albumInviteTemplate && + other.albumUpdateTemplate == albumUpdateTemplate && + other.welcomeTemplate == welcomeTemplate; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumInviteTemplate.hashCode) + + (albumUpdateTemplate.hashCode) + + (welcomeTemplate.hashCode); + + @override + String toString() => 'SystemConfigTemplateEmailsDto[albumInviteTemplate=$albumInviteTemplate, albumUpdateTemplate=$albumUpdateTemplate, welcomeTemplate=$welcomeTemplate]'; + + Map toJson() { + final json = {}; + json[r'albumInviteTemplate'] = this.albumInviteTemplate; + json[r'albumUpdateTemplate'] = this.albumUpdateTemplate; + json[r'welcomeTemplate'] = this.welcomeTemplate; + return json; + } + + /// Returns a new [SystemConfigTemplateEmailsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigTemplateEmailsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateEmailsDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigTemplateEmailsDto( + albumInviteTemplate: mapValueOfType(json, r'albumInviteTemplate')!, + albumUpdateTemplate: mapValueOfType(json, r'albumUpdateTemplate')!, + welcomeTemplate: mapValueOfType(json, r'welcomeTemplate')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigTemplateEmailsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigTemplateEmailsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigTemplateEmailsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigTemplateEmailsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumInviteTemplate', + 'albumUpdateTemplate', + 'welcomeTemplate', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_templates_dto.dart b/mobile/openapi/lib/model/system_config_templates_dto.dart new file mode 100644 index 0000000000..a5e8834978 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_templates_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigTemplatesDto { + /// Returns a new [SystemConfigTemplatesDto] instance. + SystemConfigTemplatesDto({ + required this.email, + }); + + SystemConfigTemplateEmailsDto email; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplatesDto && + other.email == email; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (email.hashCode); + + @override + String toString() => 'SystemConfigTemplatesDto[email=$email]'; + + Map toJson() { + final json = {}; + json[r'email'] = this.email; + return json; + } + + /// Returns a new [SystemConfigTemplatesDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigTemplatesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplatesDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigTemplatesDto( + email: SystemConfigTemplateEmailsDto.fromJson(json[r'email'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigTemplatesDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigTemplatesDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigTemplatesDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigTemplatesDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'email', + }; +} + diff --git a/mobile/openapi/lib/model/template_dto.dart b/mobile/openapi/lib/model/template_dto.dart new file mode 100644 index 0000000000..f818e0508a --- /dev/null +++ b/mobile/openapi/lib/model/template_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TemplateDto { + /// Returns a new [TemplateDto] instance. + TemplateDto({ + required this.template, + }); + + String template; + + @override + bool operator ==(Object other) => identical(this, other) || other is TemplateDto && + other.template == template; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (template.hashCode); + + @override + String toString() => 'TemplateDto[template=$template]'; + + Map toJson() { + final json = {}; + json[r'template'] = this.template; + return json; + } + + /// Returns a new [TemplateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TemplateDto? fromJson(dynamic value) { + upgradeDto(value, "TemplateDto"); + if (value is Map) { + final json = value.cast(); + + return TemplateDto( + template: mapValueOfType(json, r'template')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TemplateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TemplateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TemplateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TemplateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'template', + }; +} + diff --git a/mobile/openapi/lib/model/template_response_dto.dart b/mobile/openapi/lib/model/template_response_dto.dart new file mode 100644 index 0000000000..3c3224a54b --- /dev/null +++ b/mobile/openapi/lib/model/template_response_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TemplateResponseDto { + /// Returns a new [TemplateResponseDto] instance. + TemplateResponseDto({ + required this.html, + required this.name, + }); + + String html; + + String name; + + @override + bool operator ==(Object other) => identical(this, other) || other is TemplateResponseDto && + other.html == html && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (html.hashCode) + + (name.hashCode); + + @override + String toString() => 'TemplateResponseDto[html=$html, name=$name]'; + + Map toJson() { + final json = {}; + json[r'html'] = this.html; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [TemplateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TemplateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TemplateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TemplateResponseDto( + html: mapValueOfType(json, r'html')!, + name: mapValueOfType(json, r'name')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TemplateResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TemplateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TemplateResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TemplateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'html', + 'name', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc32a32e04..43985cae81 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3430,6 +3430,57 @@ ] } }, + "/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplate", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + } + }, "/notifications/test-email": { "post": { "operationId": "sendTestEmail", @@ -11538,6 +11589,9 @@ "storageTemplate": { "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" }, + "templates": { + "$ref": "#/components/schemas/SystemConfigTemplatesDto" + }, "theme": { "$ref": "#/components/schemas/SystemConfigThemeDto" }, @@ -11565,6 +11619,7 @@ "reverseGeocoding", "server", "storageTemplate", + "templates", "theme", "trash", "user" @@ -12111,6 +12166,25 @@ ], "type": "object" }, + "SystemConfigTemplateEmailsDto": { + "properties": { + "albumInviteTemplate": { + "type": "string" + }, + "albumUpdateTemplate": { + "type": "string" + }, + "welcomeTemplate": { + "type": "string" + } + }, + "required": [ + "albumInviteTemplate", + "albumUpdateTemplate", + "welcomeTemplate" + ], + "type": "object" + }, "SystemConfigTemplateStorageOptionDto": { "properties": { "dayOptions": { @@ -12174,6 +12248,17 @@ ], "type": "object" }, + "SystemConfigTemplatesDto": { + "properties": { + "email": { + "$ref": "#/components/schemas/SystemConfigTemplateEmailsDto" + } + }, + "required": [ + "email" + ], + "type": "object" + }, "SystemConfigThemeDto": { "properties": { "customCss": { @@ -12352,6 +12437,32 @@ }, "type": "object" }, + "TemplateDto": { + "properties": { + "template": { + "type": "string" + } + }, + "required": [ + "template" + ], + "type": "object" + }, + "TemplateResponseDto": { + "properties": { + "html": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "html", + "name" + ], + "type": "object" + }, "TestEmailResponseDto": { "properties": { "messageId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d786139ab5..20d0c5715f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -634,6 +634,13 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; export type SystemConfigSmtpTransportDto = { host: string; ignoreCert: boolean; @@ -1232,6 +1239,14 @@ export type SystemConfigStorageTemplateDto = { hashVerificationEnabled: boolean; template: string; }; +export type SystemConfigTemplateEmailsDto = { + albumInviteTemplate: string; + albumUpdateTemplate: string; + welcomeTemplate: string; +}; +export type SystemConfigTemplatesDto = { + email: SystemConfigTemplateEmailsDto; +}; export type SystemConfigThemeDto = { customCss: string; }; @@ -1259,6 +1274,7 @@ export type SystemConfigDto = { reverseGeocoding: SystemConfigReverseGeocodingDto; server: SystemConfigServerDto; storageTemplate: SystemConfigStorageTemplateDto; + templates: SystemConfigTemplatesDto; theme: SystemConfigThemeDto; trash: SystemConfigTrashDto; user: SystemConfigUserDto; @@ -2227,6 +2243,19 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } +export function getNotificationTemplate({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/config.ts b/server/src/config.ts index dd850e063f..2658974200 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -146,6 +146,13 @@ export interface SystemConfig { }; }; }; + templates: { + email: { + welcomeTemplate: string; + albumInviteTemplate: string; + albumUpdateTemplate: string; + }; + }; server: { externalDomain: string; loginPageMessage: string; @@ -313,6 +320,13 @@ export const defaults = Object.freeze({ }, }, }, + templates: { + email: { + welcomeTemplate: '', + albumInviteTemplate: '', + albumUpdateTemplate: '', + }, + }, user: { deleteDelay: 7, }, diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 3dd72dd73a..27034fd63a 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { EmailTemplate } from 'src/interfaces/notification.interface'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -17,4 +18,15 @@ export class NotificationController { sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } + + @Post('templates/:name') + @HttpCode(HttpStatus.OK) + @Authenticated({ admin: true }) + getNotificationTemplate( + @Auth() auth: AuthDto, + @Param('name') name: EmailTemplate, + @Body() dto: TemplateDto, + ): Promise { + return this.service.getTemplate(name, dto.template); + } } diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index 34b3923580..c1a09c801c 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,3 +1,13 @@ +import { IsString } from 'class-validator'; + export class TestEmailResponseDto { messageId!: string; } +export class TemplateResponseDto { + name!: string; + html!: string; +} +export class TemplateDto { + @IsString() + template!: string; +} diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 894f4c7948..3509182545 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -465,6 +465,24 @@ class SystemConfigNotificationsDto { smtp!: SystemConfigSmtpDto; } +class SystemConfigTemplateEmailsDto { + @IsString() + albumInviteTemplate!: string; + + @IsString() + welcomeTemplate!: string; + + @IsString() + albumUpdateTemplate!: string; +} + +class SystemConfigTemplatesDto { + @Type(() => SystemConfigTemplateEmailsDto) + @ValidateNested() + @IsObject() + email!: SystemConfigTemplateEmailsDto; +} + class SystemConfigStorageTemplateDto { @ValidateBoolean() enabled!: boolean; @@ -636,6 +654,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() notifications!: SystemConfigNotificationsDto; + @Type(() => SystemConfigTemplatesDto) + @ValidateNested() + @IsObject() + templates!: SystemConfigTemplatesDto; + @Type(() => SystemConfigServerDto) @ValidateNested() @IsObject() diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index 232ef5290d..0b3819b332 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; export const AlbumInviteEmail = ({ baseUrl, @@ -11,39 +12,64 @@ export const AlbumInviteEmail = ({ senderName, albumId, cid, -}: AlbumInviteEmailProps) => ( - - - Hey {recipientName}! - + customTemplate, +}: AlbumInviteEmailProps) => { + const variables = { + albumName, + recipientName, + senderName, + albumId, + baseUrl, + }; - - {senderName} has added you to the album {albumName}. - + const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, variables) + ) : ( + <> + + Hey {recipientName}! + - {cid && ( -

    - + + {senderName} has added you to the album {albumName}. + + + ); + + return ( + + {customTemplate && ( + +
    +
    + )} + + {!customTemplate && emailContent} + + {cid && ( +
    + +
    + )} + +
    + View Album
    - )} -
    - View Album -
    - - - If you cannot click the button use the link below to view the album. -
    - {`${baseUrl}/albums/${albumId}`} -
    -
    -); + + If you cannot click the button use the link below to view the album. +
    + {`${baseUrl}/albums/${albumId}`} +
    + + ); +}; AlbumInviteEmail.PreviewProps = { baseUrl: 'https://demo.immich.app', diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index 0fb5ad931c..9dcd858e93 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -3,47 +3,80 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; -export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( - - - Hey {recipientName}! - +export const AlbumUpdateEmail = ({ + baseUrl, + albumName, + recipientName, + albumId, + cid, + customTemplate, +}: AlbumUpdateEmailProps) => { + const usableTemplateVariables = { + albumName, + recipientName, + albumId, + baseUrl, + }; - - New media has been added to {albumName}, -
    check it out! -
    + const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, usableTemplateVariables) + ) : ( + <> + + Hey {recipientName}! + - {cid && ( -
    - + + New media has been added to {albumName}, +
    check it out! +
    + + ); + + return ( + + {customTemplate && ( + +
    +
    + )} + + {!customTemplate && emailContent} + + {cid && ( +
    + +
    + )} + +
    + View Album
    - )} -
    - View Album -
    - - - If you cannot click the button use the link below to view the album. -
    - {`${baseUrl}/albums/${albumId}`} -
    -
    -); + + If you cannot click the button use the link below to view the album. +
    + {`${baseUrl}/albums/${albumId}`} +
    + + ); +}; AlbumUpdateEmail.PreviewProps = { baseUrl: 'https://demo.immich.app', albumName: 'Trip to Europe', albumId: 'b63f6dae-e1c9-401b-9a85-9dbbf5612539', recipientName: 'Alan Turing', + cid: '', + customTemplate: '', } as AlbumUpdateEmailProps; export default AlbumUpdateEmail; diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index e031ac6b97..ced0b77698 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -3,36 +3,62 @@ import * as React from 'react'; import { ImmichButton } from 'src/emails/components/button.component'; import ImmichLayout from 'src/emails/components/immich.layout'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; +import { replaceTemplateTags } from 'src/utils/replace-template-tags'; -export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( - - - Hey {displayName}! - +export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => { + const usableTemplateVariables = { + displayName, + username, + password, + baseUrl, + }; - A new account has been created for you. + const emailContent = customTemplate ? ( + replaceTemplateTags(customTemplate, usableTemplateVariables) + ) : ( + <> + + Hey {displayName}! + - - Username: {username} - {password && ( - <> -
    - Password: {password} - + A new account has been created for you. + + + Username: {username} + {password && ( + <> +
    + Password: {password} + + )} +
    + + ); + + return ( + + {customTemplate && ( + +
    +
    )} -
    -
    - Login -
    + {!customTemplate && emailContent} - - If you cannot click the button use the link below to proceed with first login. -
    - {baseUrl} -
    -
    -); +
    + Login +
    + + + If you cannot click the button use the link below to proceed with first login. +
    + {baseUrl} +
    + + ); +}; WelcomeEmail.PreviewProps = { baseUrl: 'https://demo.immich.app/auth/login', diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index ec0ecc534b..b20b3c50ae 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -39,6 +39,7 @@ export enum EmailTemplate { interface BaseEmailProps { baseUrl: string; + customTemplate?: string; } export interface TestEmailProps extends BaseEmailProps { @@ -70,18 +71,22 @@ export type EmailRenderRequest = | { template: EmailTemplate.TEST_EMAIL; data: TestEmailProps; + customTemplate: string; } | { template: EmailTemplate.WELCOME; data: WelcomeEmailProps; + customTemplate: string; } | { template: EmailTemplate.ALBUM_INVITE; data: AlbumInviteEmailProps; + customTemplate: string; } | { template: EmailTemplate.ALBUM_UPDATE; data: AlbumUpdateEmailProps; + customTemplate: string; }; export type SendEmailResponse = { diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 983be21d2b..368ba3f0ce 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -21,6 +21,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.TEST_EMAIL, data: { displayName: 'Alen Turing', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -33,6 +34,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.WELCOME, data: { displayName: 'Alen Turing', username: 'turing', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -51,6 +53,7 @@ describe(NotificationRepository.name, () => { recipientName: 'Jane', baseUrl: 'http://localhost', }, + customTemplate: '', }; const result = await sut.renderEmail(request); @@ -63,6 +66,7 @@ describe(NotificationRepository.name, () => { const request: EmailRenderRequest = { template: EmailTemplate.ALBUM_UPDATE, data: { albumName: 'Holiday', albumId: '123', recipientName: 'Jane', baseUrl: 'http://localhost' }, + customTemplate: '', }; const result = await sut.renderEmail(request); diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 293a80576f..b2444301e5 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -55,22 +55,22 @@ export class NotificationRepository implements INotificationRepository { } } - private render({ template, data }: EmailRenderRequest): React.FunctionComponentElement { + private render({ template, data, customTemplate }: EmailRenderRequest): React.FunctionComponentElement { switch (template) { case EmailTemplate.TEST_EMAIL: { - return React.createElement(TestEmail, data); + return React.createElement(TestEmail, { ...data, customTemplate }); } case EmailTemplate.WELCOME: { - return React.createElement(WelcomeEmail, data); + return React.createElement(WelcomeEmail, { ...data, customTemplate }); } case EmailTemplate.ALBUM_INVITE: { - return React.createElement(AlbumInviteEmail, data); + return React.createElement(AlbumInviteEmail, { ...data, customTemplate }); } case EmailTemplate.ALBUM_UPDATE: { - return React.createElement(AlbumUpdateEmail, data); + return React.createElement(AlbumUpdateEmail, { ...data, customTemplate }); } } } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index e7c0201963..37b265c6ae 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -140,7 +140,7 @@ export class NotificationService extends BaseService { setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); } - async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { + async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { throw new Error('User not found'); @@ -160,8 +160,8 @@ export class NotificationService extends BaseService { baseUrl: getExternalDomain(server, port), displayName: user.name, }, + customTemplate: tempTemplate!, }); - const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', @@ -175,6 +175,69 @@ export class NotificationService extends BaseService { return { messageId }; } + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + const { port } = this.configRepository.getEnv(); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.notificationRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server, port), + displayName: 'John Doe', + username: 'john@doe.com', + password: 'thisIsAPassword123', + }, + customTemplate: customTemplate || templates.email.welcomeTemplate, + }); + + templateResponse = _welcomeHtml; + break; + } + case EmailTemplate.ALBUM_UPDATE: { + const { html: _updateAlbumHtml } = await this.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server, port), + albumId: '1', + albumName: 'Favorite Photos', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = _updateAlbumHtml; + break; + } + + case EmailTemplate.ALBUM_INVITE: { + const { html } = await this.notificationRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server, port), + albumId: '1', + albumName: "John Doe's Favorites", + senderName: 'John Doe', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = html; + break; + } + default: { + templateResponse = ''; + break; + } + } + + return { name, html: templateResponse }; + } + @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION }) async handleUserSignup({ id, tempPassword }: JobOf) { const user = await this.userRepository.get(id, { withDeleted: false }); @@ -182,7 +245,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { server } = await this.getConfig({ withCache: true }); + const { server, templates } = await this.getConfig({ withCache: true }); const { port } = this.configRepository.getEnv(); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, @@ -192,6 +255,7 @@ export class NotificationService extends BaseService { username: user.email, password: tempPassword, }, + customTemplate: templates.email.welcomeTemplate, }); await this.jobRepository.queue({ @@ -227,7 +291,7 @@ export class NotificationService extends BaseService { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.getConfig({ withCache: false }); + const { server, templates } = await this.getConfig({ withCache: false }); const { port } = this.configRepository.getEnv(); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, @@ -239,6 +303,7 @@ export class NotificationService extends BaseService { recipientName: recipient.name, cid: attachment ? attachment.cid : undefined, }, + customTemplate: templates.email.albumInviteTemplate, }); await this.jobRepository.queue({ @@ -273,7 +338,7 @@ export class NotificationService extends BaseService { ); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.getConfig({ withCache: false }); + const { server, templates } = await this.getConfig({ withCache: false }); const { port } = this.configRepository.getEnv(); for (const recipient of recipients) { @@ -297,6 +362,7 @@ export class NotificationService extends BaseService { recipientName: recipient.name, cid: attachment ? attachment.cid : undefined, }, + customTemplate: templates.email.albumUpdateTemplate, }); await this.jobRepository.queue({ diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 2550c15de2..2a20f32933 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -190,6 +190,13 @@ const updatedConfig = Object.freeze({ }, }, }, + templates: { + email: { + albumInviteTemplate: '', + welcomeTemplate: '', + albumUpdateTemplate: '', + }, + }, }); describe(SystemConfigService.name, () => { diff --git a/server/src/utils/replace-template-tags.ts b/server/src/utils/replace-template-tags.ts new file mode 100644 index 0000000000..70333d7dff --- /dev/null +++ b/server/src/utils/replace-template-tags.ts @@ -0,0 +1,5 @@ +export const replaceTemplateTags = (template: string, variables: Record) => { + return template.replaceAll(/{(.*?)}/g, (_, key) => { + return variables[key] || `{${key}}`; + }); +}; diff --git a/web/package-lock.json b/web/package-lock.json index f06484fe8f..15edeb0c28 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", "luxon": "^3.4.4", - "socket.io-client": "^4.7.5", + "socket.io-client": "~4.7.5", "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.1", "svelte-local-storage-store": "^0.6.4", diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte index 28187978f9..30a9fbad5c 100644 --- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte +++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte @@ -17,6 +17,7 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { handleError } from '$lib/utils/handle-error'; import { SettingInputFieldType } from '$lib/constants'; + import TemplateSettings from '$lib/components/admin-page/settings/template-settings/template-settings.svelte'; interface Props { savedConfig: SystemConfigDto; @@ -162,13 +163,14 @@
    - - onReset({ ...options, configKeys: ['notifications'] })} - onSave={() => onSave({ notifications: config.notifications })} - showResetToDefault={!isEqual(savedConfig, defaultConfig)} - {disabled} - />
    + + + onReset({ ...options, configKeys: ['notifications', 'templates'] })} + onSave={() => onSave({ notifications: config.notifications, templates: config.templates })} + showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} + />
    diff --git a/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte new file mode 100644 index 0000000000..c27df817c2 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/template-settings/template-settings.svelte @@ -0,0 +1,131 @@ + + +
    +
    +
    +
    + +
    +

    + + {$t('admin.template_email_if_empty')} + +

    +
    + {#if loadingPreview} + + {/if} + + {#each templateConfigs as { label, templateKey, descriptionTags, templateName }} + +
    + +
    + {/each} +
    +
    +
    + + {#if htmlPreview} + +
    + +
    +
    + {/if} +
    +
    +
    From 5060ee95c28221bfeb7cd10181493b6cd6c957d6 Mon Sep 17 00:00:00 2001 From: Tim Van Onckelen <2817556+TimVanOnckelen@users.noreply.github.com> Date: Wed, 4 Dec 2024 21:38:55 +0100 Subject: [PATCH 510/599] feat(web): Album preview overview in menu (#13981) --- i18n/en.json | 2 + .../side-bar/recent-albums.svelte | 40 ++++++++++++ .../side-bar/side-bar-link.svelte | 61 ++++++++++++++----- .../side-bar/side-bar.svelte | 17 +++++- web/src/lib/stores/preferences.store.ts | 2 + 5 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 web/src/lib/components/shared-components/side-bar/recent-albums.svelte diff --git a/i18n/en.json b/i18n/en.json index 9741c10b29..073d4ba893 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -736,6 +736,7 @@ "external": "External", "external_libraries": "External Libraries", "face_unassigned": "Unassigned", + "failed_to_load_assets": "Failed to load assets", "favorite": "Favorite", "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", @@ -1036,6 +1037,7 @@ "reassing_hint": "Assign selected assets to an existing person", "recent": "Recent", "recent_searches": "Recent searches", + "recent-albums": "Recent albums", "refresh": "Refresh", "refresh_encoded_videos": "Refresh encoded videos", "refresh_faces": "Refresh faces", diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte new file mode 100644 index 0000000000..a412d5cc42 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte @@ -0,0 +1,40 @@ + + +{#each albums as album} +
    +
    +
    +
    +
    + {album.albumName} +
    +
    +{/each} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index 13f08533c5..4da73b6288 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -1,7 +1,10 @@ - + {#if hasDropdown} + + {/if} + -
    - - {title} -
    -
    -
    + > +
    + + {title} +
    +
    + + +{#if hasDropdown && dropdownOpen} + {@render hasDropdown?.()} +{/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 000afa5d1a..9c49b971ba 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -27,6 +27,9 @@ import { t } from 'svelte-i18n'; import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { preferences } from '$lib/stores/user.store'; + import { recentAlbumsDropdown } from '$lib/stores/preferences.store'; + import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte'; + import { fly } from 'svelte/transition'; let isArchiveSelected: boolean = $state(false); let isFavoritesSelected: boolean = $state(false); @@ -88,7 +91,19 @@ bind:isSelected={isFavoritesSelected} > - + + {#snippet dropDownContent()} + + {/snippet} + {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 87f4a7ba44..2b3ff86c2f 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -144,3 +144,5 @@ export const alwaysLoadOriginalFile = persisted('always-load-original-f export const playVideoThumbnailOnHover = persisted('play-video-thumbnail-on-hover', true, {}); export const loopVideo = persisted('loop-video', true, {}); + +export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); From 3c38851d5095baa7ba1baf93abcea1d14a8b0f8b Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 5 Dec 2024 02:33:46 +0530 Subject: [PATCH 511/599] feat(mobile): native_video_player (#12104) * add native player library * splitup the player * stateful widget * refactor: native_video_player * fix: handle buffering * turn on volume when video plays * fix: aspect ratio * fix: handle remote asset orientation * refinements and fixes fix orientation for remote assets wip separate widget separate video loader widget fixed memory leak optimized seeking, cleanup debug context pop use global key back to one widget fixed rebuild wait for swipe animation to finish smooth hero animation for remote videos faster scroll animation * clean up logging * refactor aspect ratio calculation * removed unnecessary import * transitive dependencies * fixed referencing uninitialized orientation * use correct ref to build android * higher res placeholder for local videos * slightly lower delay * await things * fix controls when swiping between image and video * linting * extra smooth seeking, add comments * chore: generate router page * use current asset provider and loadAsset * fix stack handling * improved motion photo handling * use visibility for motion videos * error handling for async calls * fix duplicate key error * maybe fix duplicate key error * increase delay for hero animation * faster initialization for remote videos * ensure dimensions for memory cards * make aspect ratio logic reusable, optimizations * refactor: move exif search from aspect ratio to orientation * local orientation on ios is unreliable; prefer remote * fix no audio in silent mode on ios * increase bottom bar opacity to account for hdr * remove unused import * fix live photo play button not updating * fix map marker -> galleryviewer * remove video_player * fix hdr playback on android * fix looping * remove unused dependencies * update to latest player commit * fix player controls hiding when video is not playing * fix restart video * stop showing motion video after ending when looping is disabled * delay video initialization to avoid placeholder flicker * faster animation * shorter delay * small delay for image -> video on android * fix: lint * hide stacked children when controls are hidden, avoid bottom bar dropping --------- Co-authored-by: Alex Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- mobile/android/app/build.gradle | 4 +- .../android/app/src/main/AndroidManifest.xml | 2 +- mobile/android/build.gradle | 4 +- mobile/ios/Podfile.lock | 19 +- mobile/ios/Runner/AppDelegate.swift | 75 ++-- mobile/lib/constants/immich_colors.dart | 2 +- mobile/lib/entities/asset.entity.dart | 115 +++-- mobile/lib/entities/exif_info.entity.dart | 31 +- mobile/lib/entities/exif_info.entity.g.dart | 213 ++++++++- mobile/lib/extensions/scroll_extensions.dart | 38 ++ .../common/gallery_stacked_children.dart | 91 ++++ .../lib/pages/common/gallery_viewer.page.dart | 419 ++++++++---------- .../common/native_video_viewer.page.dart | 411 +++++++++++++++++ .../lib/pages/common/video_viewer.page.dart | 167 ------- mobile/lib/pages/photos/memory.page.dart | 4 + mobile/lib/pages/search/map/map.page.dart | 11 +- .../asset_viewer/asset_stack.provider.dart | 40 +- .../is_motion_video_playing.provider.dart | 23 + .../video_player_controller_provider.dart | 46 -- .../video_player_controller_provider.g.dart | 164 ------- .../video_player_controls_provider.dart | 96 ++-- .../video_player_value_provider.dart | 81 ++-- .../image/immich_local_image_provider.dart | 80 ++-- mobile/lib/routing/router.dart | 5 + mobile/lib/routing/router.gr.dart | 58 +++ mobile/lib/services/asset.service.dart | 26 ++ mobile/lib/utils/debounce.dart | 57 ++- .../utils/hooks/chewiew_controller_hook.dart | 161 ------- mobile/lib/utils/hooks/interval_hook.dart | 18 + mobile/lib/utils/migration.dart | 2 +- mobile/lib/utils/throttle.dart | 9 +- .../asset_grid/immich_asset_grid_view.dart | 32 +- .../asset_viewer/bottom_gallery_bar.dart | 34 +- .../custom_video_player_controls.dart | 53 +-- .../asset_viewer/detail_panel/file_info.dart | 7 +- .../widgets/asset_viewer/gallery_app_bar.dart | 23 +- .../asset_viewer/motion_photo_button.dart | 22 + .../asset_viewer/top_control_app_bar.dart | 28 +- .../widgets/asset_viewer/video_player.dart | 48 -- .../widgets/asset_viewer/video_position.dart | 8 +- mobile/lib/widgets/common/immich_image.dart | 9 +- mobile/lib/widgets/memories/memory_card.dart | 24 +- mobile/pubspec.lock | 99 +---- mobile/pubspec.yaml | 9 +- 44 files changed, 1625 insertions(+), 1243 deletions(-) create mode 100644 mobile/lib/extensions/scroll_extensions.dart create mode 100644 mobile/lib/pages/common/gallery_stacked_children.dart create mode 100644 mobile/lib/pages/common/native_video_viewer.page.dart delete mode 100644 mobile/lib/pages/common/video_viewer.page.dart create mode 100644 mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controller_provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart delete mode 100644 mobile/lib/utils/hooks/chewiew_controller_hook.dart create mode 100644 mobile/lib/utils/hooks/interval_hook.dart create mode 100644 mobile/lib/widgets/asset_viewer/motion_photo_button.dart delete mode 100644 mobile/lib/widgets/asset_viewer/video_player.dart diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 506ee9d1a4..0ec511d9f1 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 34 + compileSdkVersion 35 compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -47,7 +47,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c85ce13684..8f239015dd 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ + android:value="true" /> Bool { - // Required for flutter_local_notification - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + // Required for flutter_local_notification + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to set audio session category. Error: \(error)") + } + + GeneratedPluginRegistrant.register(with: self) + BackgroundServicePlugin.registerBackgroundProcessing() + + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.setPluginRegistrantCallback { registry in + if !registry.hasPlugin("org.cocoapods.path-provider-ios") { + FLTPathProviderPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) } - GeneratedPluginRegistrant.register(with: self) - BackgroundServicePlugin.registerBackgroundProcessing() - - BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) - - BackgroundServicePlugin.setPluginRegistrantCallback { registry in - if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) - } - - if !registry.hasPlugin("org.cocoapods.photo-manager") { - PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) - } - - if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) - } - - if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { - PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) - } + if !registry.hasPlugin("org.cocoapods.photo-manager") { + PhotoManagerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) } - - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + + if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { + SharedPreferencesPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) + } + + if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { + PermissionHandlerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - + } diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index a49e783602..847887de8c 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); -const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0); const Color red400 = Color(0xFFEF5350); +const Color grey200 = Color(0xFFEEEEEE); final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 182c10307f..4bec35970a 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -22,12 +23,8 @@ class Asset { durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, - height = isFlipped(remote) - ? remote.exifInfo?.exifImageWidth?.toInt() - : remote.exifInfo?.exifImageHeight?.toInt(), - width = isFlipped(remote) - ? remote.exifInfo?.exifImageHeight?.toInt() - : remote.exifInfo?.exifImageWidth?.toInt(), + height = remote.exifInfo?.exifImageHeight?.toInt(), + width = remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), exifInfo = @@ -93,6 +90,27 @@ class Asset { set local(AssetEntity? assetEntity) => _local = assetEntity; + @ignore + bool _didUpdateLocal = false; + + @ignore + Future get localAsync async { + final local = this.local; + if (local == null) { + throw Exception('Asset $fileName has no local data'); + } + + final updatedLocal = + _didUpdateLocal ? local : await local.obtainForNewProperties(); + if (updatedLocal == null) { + throw Exception('Could not fetch local data for $fileName'); + } + + this.local = updatedLocal; + _didUpdateLocal = true; + return updatedLocal; + } + Id id = Isar.autoIncrement; /// stores the raw SHA1 bytes as a base64 String @@ -150,10 +168,21 @@ class Asset { int stackCount; - /// Aspect ratio of the asset + /// Returns null if the asset has no sync access to the exif info @ignore - double? get aspectRatio => - width == null || height == null ? 0 : width! / height!; + double? get aspectRatio { + final orientatedWidth = this.orientatedWidth; + final orientatedHeight = this.orientatedHeight; + + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth.toDouble() / orientatedHeight.toDouble(); + } + + return null; + } /// `true` if this [Asset] is present on the device @ignore @@ -172,6 +201,12 @@ class Asset { @ignore bool get isImage => type == AssetType.image; + @ignore + bool get isVideo => type == AssetType.video; + + @ignore + bool get isMotionPhoto => livePhotoVideoId != null; + @ignore AssetState get storage { if (isRemote && isLocal) { @@ -192,6 +227,50 @@ class Asset { @ignore set byteHash(List hash) => checksum = base64.encode(hash); + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + bool? get isFlipped { + final exifInfo = this.exifInfo; + if (exifInfo != null) { + return exifInfo.isFlipped; + } + + if (_didUpdateLocal && Platform.isAndroid) { + final local = this.local; + if (local == null) { + throw Exception('Asset $fileName has no local data'); + } + return local.orientation == 90 || local.orientation == 270; + } + + return null; + } + + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + int? get orientatedHeight { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + + return isFlipped ? width : height; + } + + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + int? get orientatedWidth { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + + return isFlipped ? height : width; + } + @override bool operator ==(other) { if (other is! Asset) return false; @@ -511,21 +590,3 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } - -/// Returns `true` if this [int] is flipped 90° clockwise -bool isRotated90CW(int orientation) { - return [7, 8, -90].contains(orientation); -} - -/// Returns `true` if this [int] is flipped 270° clockwise -bool isRotated270CW(int orientation) { - return [5, 6, 90].contains(orientation); -} - -/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise -bool isFlipped(AssetResponseDto response) { - final int orientation = - int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0; - return orientation != 0 && - (isRotated90CW(orientation) || isRotated270CW(orientation)); -} diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 63d06f5d2c..c46f3dddc1 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -23,6 +23,7 @@ class ExifInfo { String? state; String? country; String? description; + String? orientation; @ignore bool get hasCoordinates => @@ -45,6 +46,13 @@ class ExifInfo { @ignore String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; + @ignore + bool? _isFlipped; + + @ignore + @pragma('vm:prefer-inline') + bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); + @ignore double? get latitude => lat; @@ -67,7 +75,8 @@ class ExifInfo { city = dto.city, state = dto.state, country = dto.country, - description = dto.description; + description = dto.description, + orientation = dto.orientation; ExifInfo({ this.id, @@ -87,6 +96,7 @@ class ExifInfo { this.state, this.country, this.description, + this.orientation, }); ExifInfo copyWith({ @@ -107,6 +117,7 @@ class ExifInfo { String? state, String? country, String? description, + String? orientation, }) => ExifInfo( id: id ?? this.id, @@ -126,6 +137,7 @@ class ExifInfo { state: state ?? this.state, country: country ?? this.country, description: description ?? this.description, + orientation: orientation ?? this.orientation, ); @override @@ -147,7 +159,8 @@ class ExifInfo { city == other.city && state == other.state && country == other.country && - description == other.description; + description == other.description && + orientation == other.orientation; } @override @@ -169,7 +182,8 @@ class ExifInfo { city.hashCode ^ state.hashCode ^ country.hashCode ^ - description.hashCode; + description.hashCode ^ + orientation.hashCode; @override String toString() { @@ -192,10 +206,21 @@ class ExifInfo { state: $state, country: $country, description: $description, + orientation: $orientation }"""; } } +bool _isOrientationFlipped(String? orientation) { + final value = orientation != null ? int.tryParse(orientation) : null; + if (value == null) { + return false; + } + final isRotated90CW = value == 5 || value == 6 || value == 90; + final isRotated270CW = value == 7 || value == 8 || value == -90; + return isRotated90CW || isRotated270CW; +} + double? _exposureTimeToSeconds(String? s) { if (s == null) { return null; diff --git a/mobile/lib/entities/exif_info.entity.g.dart b/mobile/lib/entities/exif_info.entity.g.dart index 015983abf2..0b744e5f20 100644 --- a/mobile/lib/entities/exif_info.entity.g.dart +++ b/mobile/lib/entities/exif_info.entity.g.dart @@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema( name: r'model', type: IsarType.string, ), - r'state': PropertySchema( + r'orientation': PropertySchema( id: 14, + name: r'orientation', + type: IsarType.string, + ), + r'state': PropertySchema( + id: 15, name: r'state', type: IsarType.string, ), r'timeZone': PropertySchema( - id: 15, + id: 16, name: r'timeZone', type: IsarType.string, ) @@ -154,6 +159,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.orientation; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.state; if (value != null) { @@ -189,8 +200,9 @@ void _exifInfoSerialize( writer.writeString(offsets[11], object.make); writer.writeFloat(offsets[12], object.mm); writer.writeString(offsets[13], object.model); - writer.writeString(offsets[14], object.state); - writer.writeString(offsets[15], object.timeZone); + writer.writeString(offsets[14], object.orientation); + writer.writeString(offsets[15], object.state); + writer.writeString(offsets[16], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize( make: reader.readStringOrNull(offsets[11]), mm: reader.readFloatOrNull(offsets[12]), model: reader.readStringOrNull(offsets[13]), - state: reader.readStringOrNull(offsets[14]), - timeZone: reader.readStringOrNull(offsets[15]), + orientation: reader.readStringOrNull(offsets[14]), + state: reader.readStringOrNull(offsets[15]), + timeZone: reader.readStringOrNull(offsets[16]), ); return object; } @@ -260,6 +273,8 @@ P _exifInfoDeserializeProp

    ( return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; + case 16: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter }); } + QueryBuilder orientationIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'orientation', + )); + }); + } + + QueryBuilder + orientationIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'orientation', + )); + }); + } + + QueryBuilder orientationEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + orientationGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'orientation', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'orientation', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: '', + )); + }); + } + + QueryBuilder + orientationIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'orientation', + value: '', + )); + }); + } + QueryBuilder stateIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder sortByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder sortByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder thenByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder thenByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByOrientation( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByState( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty }); } + QueryBuilder orientationProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'orientation'); + }); + } + QueryBuilder stateProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'state'); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart new file mode 100644 index 0000000000..5bbd73163a --- /dev/null +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; + +// https://stackoverflow.com/a/74453792 +class FastScrollPhysics extends ScrollPhysics { + const FastScrollPhysics({super.parent}); + + @override + FastScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => const SpringDescription( + mass: 40, + stiffness: 100, + damping: 1, + ); +} + +class FastClampingScrollPhysics extends ClampingScrollPhysics { + const FastClampingScrollPhysics({super.parent}); + + @override + FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastClampingScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => const SpringDescription( + // When swiping between videos on Android, the placeholder of the first opened video + // can briefly be seen and cause a flicker effect if the video begins to initialize + // before the animation finishes - probably a bug in PhotoViewGallery's animation handling + // Making the animation faster is not just stylistic, but also helps to avoid this flicker + mass: 80, + stiffness: 100, + damping: 1, + ); +} diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart new file mode 100644 index 0000000000..eafc325049 --- /dev/null +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; + +class GalleryStackedChildren extends HookConsumerWidget { + final ValueNotifier stackIndex; + + const GalleryStackedChildren(this.stackIndex, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } + + final stackId = asset.stackId; + if (stackId == null) { + return const SizedBox(); + } + + final stackElements = ref.watch(assetStackStateProvider(stackId)); + final showControls = ref.watch(showControlsProvider); + + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: showControls ? 1.0 : 0.0, + child: SizedBox( + height: 80, + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemBuilder: (context, index) { + final currentAsset = stackElements.elementAt(index); + final assetId = currentAsset.remoteId; + if (assetId == null) { + return const SizedBox(); + } + + return Padding( + key: ValueKey(currentAsset.id), + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + stackIndex.value = index; + ref.read(currentAssetProvider.notifier).set(currentAsset); + }, + child: Container( + width: 60, + height: 60, + decoration: index == stackIndex.value + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Image( + fit: BoxFit.cover, + image: ImmichRemoteImageProvider(assetId: assetId), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 5747332587..2ea446ea71 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,18 +8,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; +import 'package:immich_mobile/pages/common/gallery_stacked_children.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; @@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri @RoutePage() // ignore: must_be_immutable +/// Expects [currentAssetProvider] to be set before navigating to this page class GalleryViewerPage extends HookConsumerWidget { final int initialIndex; final int heroOffset; @@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsServiceProvider); - final loadAsset = renderList.loadAsset; final totalAssets = useState(renderList.totalAssets); - final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final isZoomed = useState(false); - final isPlayingVideo = useState(false); - final localPosition = useState(null); - final currentIndex = useState(initialIndex); - final currentAsset = loadAsset(currentIndex.value); - - // Update is playing motion video - ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { - isPlayingVideo.value = state == VideoPlaybackState.playing; - }); - - final stackIndex = useState(-1); - final stack = showStack && currentAsset.stackCount > 0 - ? ref.watch(assetStackStateProvider(currentAsset)) - : []; - final stackElements = showStack ? [currentAsset, ...stack] : []; - // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == noDbId; - - Asset asset = stackIndex.value == -1 - ? currentAsset - : stackElements.elementAt(stackIndex.value); - - final isMotionPhoto = asset.livePhotoVideoId != null; - // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page - ref.listen(currentAssetProvider, (_, __) {}); - useEffect( - () { - // Delay state update to after the execution of build method - Future.microtask( - () => ref.read(currentAssetProvider.notifier).set(asset), - ); - return null; - }, - [asset], - ); - - useEffect( - () { - shouldLoopVideo.value = - settings.getSetting(AppSettingsEnum.loopVideo); - return null; - }, - [], - ); + final stackIndex = useState(0); + final localPosition = useRef(null); + final currentIndex = useValueNotifier(initialIndex); + final loadAsset = renderList.loadAsset; Future precacheNextImage(int index) async { + if (!context.mounted) { + return; + } + void onError(Object exception, StackTrace? stackTrace) { // swallow error silently - debugPrint('Error precaching next image: $exception, $stackTrace'); + log.severe('Error precaching next image: $exception, $stackTrace'); } try { if (index < totalAssets.value && index >= 0) { final asset = loadAsset(index); await precacheImage( - ImmichImage.imageProvider(asset: asset), + ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, + ), context, onError: onError, ); } } catch (e) { // swallow error silently - debugPrint('Error precaching next image: $e'); + log.severe('Error precaching next image: $e'); context.maybePop(); } } + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + // Delay this a bit so we can finish loading the page + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(currentIndex.value + 1); + }); + + return null; + }, + const [], + ); + void showInfo() { + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(15.0)), @@ -183,86 +172,100 @@ class GalleryViewerPage extends HookConsumerWidget { } } - useEffect( - () { - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - isPlayingVideo.value = false; - return null; - }, - [], - ); - - useEffect( - () { - // No need to await this - unawaited( - // Delay this a bit so we can finish loading the page - Future.delayed(const Duration(milliseconds: 400)).then( - // Precache the next image - (_) => precacheNextImage(currentIndex.value + 1), - ), - ); - return null; - }, - [], - ); - ref.listen(showControlsProvider, (_, show) { - if (show) { + if (show || Platform.isIOS) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return; } + + // This prevents the bottom bar from "dropping" while the controls are being hidden + Timer(const Duration(milliseconds: 100), () { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + }); }); - Widget buildStackedChildren() { - return ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: stackElements.length, - padding: const EdgeInsets.only( - left: 5, - right: 5, - bottom: 30, - ), - itemBuilder: (context, index) { - final assetId = stackElements.elementAt(index).remoteId; - return Padding( - padding: const EdgeInsets.only(right: 5), - child: GestureDetector( - onTap: () => stackIndex.value = index, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: (stackIndex.value == -1 && index == 0) || - index == stackIndex.value - ? Border.all( - color: Colors.white, - width: 2, - ) - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image( - fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId!), - ), - ), - ), - ), - ); + PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) { + return PhotoViewGalleryPageOptions( + onDragStart: (_, details, __) { + localPosition.value = details.localPosition; }, + onDragUpdate: (_, details, __) { + handleSwipeUpDown(details); + }, + onTapDown: (_, __, ___) { + ref.read(showControlsProvider.notifier).toggle(); + }, + onLongPressStart: asset.isMotionPhoto + ? (_, __, ___) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + } + : null, + imageProvider: ImmichImage.imageProvider(asset: asset), + heroAttributes: _getHeroAttributes(asset), + filterQuality: FilterQuality.high, + tightMode: true, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) => ImmichImage( + asset, + fit: BoxFit.contain, + ), ); } + PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { + // This key is to prevent the video player from being re-initialized during the hero animation + final key = GlobalKey(); + return PhotoViewGalleryPageOptions.customChild( + onDragStart: (_, details, __) => + localPosition.value = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + heroAttributes: _getHeroAttributes(asset), + filterQuality: FilterQuality.high, + initialScale: 1.0, + maxScale: 1.0, + minScale: 1.0, + basePosition: Alignment.center, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: key, + asset: asset, + image: Image( + key: ValueKey(asset), + image: ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, + ), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ); + } + + PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + var newAsset = loadAsset(index); + final stackId = newAsset.stackId; + if (stackId != null && currentIndex.value == index) { + final stackElements = + ref.read(assetStackStateProvider(newAsset.stackId!)); + if (stackIndex.value < stackElements.length) { + newAsset = stackElements.elementAt(stackIndex.value); + } + } + + if (newAsset.isImage && !newAsset.isMotionPhoto) { + return buildImage(context, newAsset); + } + return buildVideo(context, newAsset); + } + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -272,128 +275,79 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( + key: const ValueKey('gallery'), scaleStateChangedCallback: (state) { - isZoomed.value = state != PhotoViewScaleState.initial; - ref.read(showControlsProvider.notifier).show = !isZoomed.value; + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } + + if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) { + isZoomed.value = state != PhotoViewScaleState.initial; + ref.read(showControlsProvider.notifier).show = + !isZoomed.value; + } }, - loadingBuilder: (context, event, index) => ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, + gaplessPlayback: true, + loadingBuilder: (context, event, index) { + final asset = loadAsset(index); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), ), - ), - ImmichThumbnail( - asset: asset, - fit: BoxFit.contain, - ), - ], - ), - ), + ImmichThumbnail( + key: ValueKey(asset), + asset: asset, + fit: BoxFit.contain, + ), + ], + ), + ); + }, pageController: controller, scrollPhysics: isZoomed.value ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in : (Platform.isIOS - ? const ScrollPhysics() // Use bouncing physics for iOS - : const ClampingScrollPhysics() // Use heavy physics for Android + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, - onPageChanged: (value) async { + onPageChanged: (value) { final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); + final newAsset = loadAsset(value); + currentIndex.value = value; - stackIndex.value = -1; - isPlayingVideo.value = false; + stackIndex.value = 0; - // Wait for page change animation to finish - await Future.delayed(const Duration(milliseconds: 400)); - // Then precache the next image - unawaited(precacheNextImage(next)); - }, - builder: (context, index) { - final a = - index == currentIndex.value ? asset : loadAsset(index); - - final ImageProvider provider = - ImmichImage.imageProvider(asset: a); - - if (a.isImage && !isPlayingVideo.value) { - return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - onTapDown: (_, __, ___) { - ref.read(showControlsProvider.notifier).toggle(); - }, - onLongPressStart: (_, __, ___) { - if (asset.livePhotoVideoId != null) { - isPlayingVideo.value = true; - } - }, - imageProvider: provider, - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - transitionOnUserGestures: true, - ), - filterQuality: FilterQuality.high, - tightMode: true, - minScale: PhotoViewComputedScale.contained, - errorBuilder: (context, error, stackTrace) => ImmichImage( - a, - fit: BoxFit.contain, - ), - ); - } else { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => - localPosition.value = details.localPosition, - onDragUpdate: (_, details, __) => - handleSwipeUpDown(details), - heroAttributes: PhotoViewHeroAttributes( - tag: isFromDto - ? '${currentAsset.remoteId}-$heroOffset' - : currentAsset.id + heroOffset, - ), - filterQuality: FilterQuality.high, - maxScale: 1.0, - minScale: 1.0, - basePosition: Alignment.center, - child: VideoViewerPage( - key: ValueKey(a), - asset: a, - isMotionVideo: a.livePhotoVideoId != null, - loopVideo: shouldLoopVideo.value, - placeholder: Image( - image: provider, - fit: BoxFit.contain, - height: context.height, - width: context.width, - alignment: Alignment.center, - ), - ), - ); + ref.read(currentAssetProvider.notifier).set(newAsset); + if (newAsset.isVideo || newAsset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); } + + // Wait for page change animation to finish, then precache the next image + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(next); + }); }, + builder: buildAsset, ), Positioned( top: 0, left: 0, right: 0, child: GalleryAppBar( - asset: asset, + key: const ValueKey('app-bar'), showInfo: showInfo, - isPlayingVideo: isPlayingVideo.value, - onToggleMotionVideo: () => - isPlayingVideo.value = !isPlayingVideo.value, ), ), Positioned( @@ -402,22 +356,15 @@ class GalleryViewerPage extends HookConsumerWidget { right: 0, child: Column( children: [ - Visibility( - visible: stack.isNotEmpty, - child: SizedBox( - height: 80, - child: buildStackedChildren(), - ), - ), + GalleryStackedChildren(stackIndex), BottomGalleryBar( + key: const ValueKey('bottom-bar'), renderList: renderList, totalAssets: totalAssets, controller: controller, showStack: showStack, - stackIndex: stackIndex.value, - asset: asset, + stackIndex: stackIndex, assetIndex: currentIndex, - showVideoPlayerControls: !asset.isImage && !isMotionPhoto, ), ], ), @@ -428,4 +375,14 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } + + @pragma('vm:prefer-inline') + PhotoViewHeroAttributes _getHeroAttributes(Asset asset) { + return PhotoViewHeroAttributes( + tag: asset.isInDb + ? asset.id + heroOffset + : '${asset.remoteId}-$heroOffset', + transitionOnUserGestures: true, + ); + } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart new file mode 100644 index 0000000000..536c7f6303 --- /dev/null +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; +import 'package:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +@RoutePage() +class NativeVideoViewerPage extends HookConsumerWidget { + final Asset asset; + final bool showControls; + final Widget image; + + const NativeVideoViewerPage({ + super.key, + required this.asset, + required this.image, + this.showControls = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final lastVideoPosition = useRef(-1); + final isBuffering = useRef(false); + final showMotionVideo = useState(false); + + // When a video is opened through the timeline, `isCurrent` will immediately be true. + // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. + // If the swipe is completed, `isCurrent` will be true for video B after a delay. + // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. + final currentAsset = useState(ref.read(currentAssetProvider)); + final isCurrent = currentAsset.value == asset; + + // Used to show the placeholder during hero animations for remote videos to avoid a stutter + final isVisible = + useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto); + + final log = Logger('NativeVideoViewerPage'); + + ref.listen(isPlayingMotionVideoProvider, (_, value) async { + final videoController = controller.value; + if (!asset.isMotionPhoto || videoController == null || !context.mounted) { + return; + } + + showMotionVideo.value = value; + try { + if (value) { + await videoController.seekTo(0); + await videoController.play(); + } else { + await videoController.pause(); + } + } catch (error) { + log.severe('Error toggling motion video: $error'); + } + }); + + Future createSource() async { + if (!context.mounted) { + return null; + } + + try { + final local = asset.local; + if (local != null && !asset.isMotionPhoto) { + final file = await local.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + final source = await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + return source; + } + + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' + : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; + + final source = await VideoSource.init( + path: videoUrl, + type: VideoSourceType.network, + headers: ApiService.getRequestHeaders(), + ); + return source; + } catch (error) { + log.severe( + 'Error creating video source for asset ${asset.fileName}: $error', + ); + return null; + } + } + + final videoSource = useMemoized>(() => createSource()); + final aspectRatio = useState(asset.aspectRatio); + useMemoized( + () async { + if (!context.mounted || aspectRatio.value != null) { + return null; + } + + try { + aspectRatio.value = + await ref.read(assetServiceProvider).getAspectRatio(asset); + } catch (error) { + log.severe( + 'Error getting aspect ratio for asset ${asset.fileName}: $error', + ); + } + }, + ); + + void checkIfBuffering() { + if (!context.mounted) { + return; + } + + final videoPlayback = ref.read(videoPlaybackValueProvider); + if ((isBuffering.value || + videoPlayback.state == VideoPlaybackState.initializing) && + videoPlayback.state != VideoPlaybackState.buffering) { + ref.read(videoPlaybackValueProvider.notifier).value = + videoPlayback.copyWith(state: VideoPlaybackState.buffering); + } + } + + // Timer to mark videos as buffering if the position does not change + useInterval(const Duration(seconds: 5), checkIfBuffering); + + // When the position changes, seek to the position + // Debounce the seek to avoid seeking too often + // But also don't delay the seek too much to maintain visual feedback + final seekDebouncer = useDebouncer( + interval: const Duration(milliseconds: 100), + maxWaitTime: const Duration(milliseconds: 200), + ); + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { + return; + } + + final oldSeek = (oldControls?.position ?? 0) ~/ 1; + final newSeek = newControls.position ~/ 1; + if (oldSeek != newSeek || newControls.restarted) { + seekDebouncer.run(() => playerController.seekTo(newSeek)); + } + + if (oldControls?.pause != newControls.pause || newControls.restarted) { + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + try { + if (newControls.pause) { + await playerController.pause(); + } else { + await playerController.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } + }); + + void onPlaybackReady() async { + final videoController = controller.value; + if (videoController == null || !isCurrent || !context.mounted) { + return; + } + + final videoPlayback = + VideoPlaybackValue.fromNativeController(videoController); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + + try { + if (asset.isVideo || showMotionVideo.value) { + await videoController.play(); + } + await videoController.setVolume(0.9); + } catch (error) { + log.severe('Error playing video: $error'); + } + } + + void onPlaybackStatusChanged() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + final videoPlayback = + VideoPlaybackValue.fromNativeController(videoController); + if (videoPlayback.state == VideoPlaybackState.playing) { + // Sync with the controls playing + WakelockPlus.enable(); + } else { + // Sync with the controls pause + WakelockPlus.disable(); + } + + ref.read(videoPlaybackValueProvider.notifier).status = + videoPlayback.state; + } + + void onPlaybackPositionChanged() { + // When seeking, these events sometimes move the slider to an older position + if (seekDebouncer.isActive) { + return; + } + + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + final playbackInfo = videoController.playbackInfo; + if (playbackInfo == null) { + return; + } + + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: playbackInfo.position); + + // Check if the video is buffering + if (playbackInfo.status == PlaybackStatus.playing) { + isBuffering.value = lastVideoPosition.value == playbackInfo.position; + lastVideoPosition.value = playbackInfo.position; + } else { + isBuffering.value = false; + lastVideoPosition.value = -1; + } + } + + void onPlaybackEnded() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + if (showMotionVideo.value && + videoController.playbackInfo?.status == PlaybackStatus.stopped && + !ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo)) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + + void removeListeners(NativeVideoPlayerController controller) { + controller.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.onPlaybackStatusChanged + .removeListener(onPlaybackStatusChanged); + controller.onPlaybackReady.removeListener(onPlaybackReady); + controller.onPlaybackEnded.removeListener(onPlaybackEnded); + } + + void initController(NativeVideoPlayerController nc) async { + if (controller.value != null || !context.mounted) { + return; + } + ref.read(videoPlayerControlsProvider.notifier).reset(); + ref.read(videoPlaybackValueProvider.notifier).reset(); + + final source = await videoSource; + if (source == null) { + return; + } + + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); + nc.onPlaybackReady.addListener(onPlaybackReady); + nc.onPlaybackEnded.addListener(onPlaybackEnded); + + nc.loadVideoSource(source).catchError((error) { + log.severe('Error loading video source: $error'); + }); + final loopVideo = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo); + nc.setLoop(loopVideo); + + controller.value = nc; + Timer(const Duration(milliseconds: 200), checkIfBuffering); + } + + ref.listen(currentAssetProvider, (_, value) { + final playerController = controller.value; + if (playerController != null && value != asset) { + removeListeners(playerController); + } + + final curAsset = currentAsset.value; + if (curAsset == asset) { + return; + } + + final imageToVideo = curAsset != null && !curAsset.isVideo; + + // No need to delay video playback when swiping from an image to a video + if (imageToVideo && Platform.isIOS) { + currentAsset.value = value; + onPlaybackReady(); + return; + } + + // Delay the video playback to avoid a stutter in the swipe animation + Timer( + Platform.isIOS + ? const Duration(milliseconds: 300) + : imageToVideo + ? const Duration(milliseconds: 200) + : const Duration(milliseconds: 400), () { + if (!context.mounted) { + return; + } + + currentAsset.value = value; + if (currentAsset.value == asset) { + onPlaybackReady(); + } + }); + }); + + useEffect( + () { + // If opening a remote video from a hero animation, delay visibility to avoid a stutter + final timer = isVisible.value + ? null + : Timer( + const Duration(milliseconds: 300), + () => isVisible.value = true, + ); + + return () { + timer?.cancel(); + final playerController = controller.value; + if (playerController == null) { + return; + } + removeListeners(playerController); + playerController.stop().catchError((error) { + log.fine('Error stopping video: $error'); + }); + + WakelockPlus.disable(); + }; + }, + const [], + ); + + return Stack( + children: [ + // This remains under the video to avoid flickering + // For motion videos, this is the image portion of the asset + Center(key: ValueKey(asset.id), child: image), + if (aspectRatio.value != null) + Visibility.maintain( + key: ValueKey(asset), + visible: + (asset.isVideo || showMotionVideo.value) && isVisible.value, + child: Center( + key: ValueKey(asset), + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ), + ), + ), + if (showControls) const Center(child: CustomVideoPlayerControls()), + ], + ); + } +} diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart deleted file mode 100644 index 774d4eb31e..0000000000 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_player.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -class VideoViewerPage extends HookConsumerWidget { - final Asset asset; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoViewerPage({ - super.key, - required this.asset, - this.isMotionVideo = false, - this.placeholder, - this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), - this.showDownloadingIndicator = true, - this.loopVideo = false, - }); - - @override - build(BuildContext context, WidgetRef ref) { - final controller = - ref.watch(videoPlayerControllerProvider(asset: asset)).value; - // The last volume of the video used when mute is toggled - final lastVolume = useState(0.5); - - // When the volume changes, set the volume - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, mute) { - if (mute) { - controller?.setVolume(0.0); - } else { - controller?.setVolume(lastVolume.value); - } - }); - - // When the position changes, seek to the position - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - if (controller == null) { - // No seeeking if there is no video - return; - } - - // Find the position to seek to - final Duration seek = controller.value.duration * (position / 100.0); - controller.seekTo(seek); - }); - - // When the custom video controls paus or plays - ref.listen(videoPlayerControlsProvider.select((value) => value.pause), - (lastPause, pause) { - if (pause) { - controller?.pause(); - } else { - controller?.play(); - } - }); - - // Updates the [videoPlaybackValueProvider] with the current - // position and duration of the video from the Chewie [controller] - // Also sets the error if there is an error in the playback - void updateVideoPlayback() { - final videoPlayback = VideoPlaybackValue.fromController(controller); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - final state = videoPlayback.state; - - // Enable the WakeLock while the video is playing - if (state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - } - - // Adds and removes the listener to the video player - useEffect( - () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); - // Guard no controller - if (controller == null) { - return null; - } - - // Hide the controls - // Done in a microtask to avoid setting the state while the is building - if (!isMotionVideo) { - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - // Subscribes to listener - Future.microtask(() { - controller.addListener(updateVideoPlayback); - }); - return () { - // Removes listener when we dispose - controller.removeListener(updateVideoPlayback); - controller.pause(); - }; - }, - [controller], - ); - - return PopScope( - onPopInvokedWithResult: (didPop, _) { - ref.read(videoPlaybackValueProvider.notifier).value = - VideoPlaybackValue.uninitialized(); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: Stack( - children: [ - Visibility( - visible: controller == null, - child: Stack( - children: [ - if (placeholder != null) placeholder!, - const Positioned.fill( - child: Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), - ), - ), - ), - ], - ), - ), - if (controller != null) - SizedBox( - height: context.height, - width: context.width, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 3f86f5be08..74a94ed6ee 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget { } // Precache the asset + final size = MediaQuery.sizeOf(context); await precacheImage( ImmichImage.imageProvider( asset: asset, + width: size.width, + height: size.height, ), context, + size: size, ); } diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 8000c7e339..10fe8de541 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; @@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget { useEffect( () { + final currentAssetLink = + ref.read(currentAssetProvider.notifier).ref.keepAlive(); + loadMarkers(); - return null; + return currentAssetLink.close; }, [], ); @@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget { GroupAssetsBy.none, ); + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } context.pushRoute( GalleryViewerRoute( initialIndex: 0, diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index c3e4414b39..407aef1610 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { - final Asset _asset; + final String _stackId; final Ref _ref; - AssetStackNotifier( - this._asset, - this._ref, - ) : super([]) { - fetchStackChildren(); + AssetStackNotifier(this._stackId, this._ref) : super([]) { + _fetchStack(_stackId); } - void fetchStackChildren() async { - if (mounted) { - state = await _ref.read(assetStackProvider(_asset).future); + void _fetchStack(String stackId) async { + if (!mounted) { + return; + } + + final stack = await _ref.read(assetStackProvider(stackId).future); + if (stack.isNotEmpty) { + state = stack; } } void removeChild(int index) { if (index < state.length) { state.removeAt(index); + state = List.from(state); } } } final assetStackStateProvider = StateNotifierProvider.autoDispose - .family, Asset>( - (ref, asset) => AssetStackNotifier(asset, ref), + .family, String>( + (ref, stackId) => AssetStackNotifier(stackId, ref), ); final assetStackProvider = - FutureProvider.autoDispose.family, Asset>((ref, asset) async { - // Guard [local asset] - if (asset.remoteId == null) { - return []; - } - - return await ref + FutureProvider.autoDispose.family, String>((ref, stackId) { + return ref .watch(dbProvider) .assets .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackPrimaryAssetIdEqualTo(asset.remoteId) - .sortByFileCreatedAtDesc() + .stackIdEqualTo(stackId) + // orders primary asset first as its ID is null + .sortByStackPrimaryAssetId() + .thenByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart new file mode 100644 index 0000000000..4af061f954 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart @@ -0,0 +1,23 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Whether to display the video part of a motion photo +final isPlayingMotionVideoProvider = + StateNotifierProvider((ref) { + return IsPlayingMotionVideo(ref); +}); + +class IsPlayingMotionVideo extends StateNotifier { + IsPlayingMotionVideo(this.ref) : super(false); + + final Ref ref; + + bool get playing => state; + + set playing(bool value) { + state = value; + } + + void toggle() { + state = !state; + } +} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart deleted file mode 100644 index 969e181cbb..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:video_player/video_player.dart'; - -part 'video_player_controller_provider.g.dart'; - -@riverpod -Future videoPlayerController( - VideoPlayerControllerRef ref, { - required Asset asset, -}) async { - late VideoPlayerController controller; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - controller = VideoPlayerController.file(file); - } else { - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${asset.remoteId}/video/playback'; - - final url = Uri.parse(videoUrl); - controller = VideoPlayerController.networkUrl( - url, - httpHeaders: ApiService.getRequestHeaders(), - videoPlayerOptions: asset.livePhotoVideoId != null - ? VideoPlayerOptions(mixWithOthers: true) - : VideoPlayerOptions(mixWithOthers: false), - ); - } - - await controller.initialize(); - - ref.onDispose(() { - controller.dispose(); - }); - - return controller; -} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart deleted file mode 100644 index 00ad37648a..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart +++ /dev/null @@ -1,164 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'video_player_controller_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$videoPlayerControllerHash() => - r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [videoPlayerController]. -@ProviderFor(videoPlayerController) -const videoPlayerControllerProvider = VideoPlayerControllerFamily(); - -/// See also [videoPlayerController]. -class VideoPlayerControllerFamily - extends Family> { - /// See also [videoPlayerController]. - const VideoPlayerControllerFamily(); - - /// See also [videoPlayerController]. - VideoPlayerControllerProvider call({ - required Asset asset, - }) { - return VideoPlayerControllerProvider( - asset: asset, - ); - } - - @override - VideoPlayerControllerProvider getProviderOverride( - covariant VideoPlayerControllerProvider provider, - ) { - return call( - asset: provider.asset, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'videoPlayerControllerProvider'; -} - -/// See also [videoPlayerController]. -class VideoPlayerControllerProvider - extends AutoDisposeFutureProvider { - /// See also [videoPlayerController]. - VideoPlayerControllerProvider({ - required Asset asset, - }) : this._internal( - (ref) => videoPlayerController( - ref as VideoPlayerControllerRef, - asset: asset, - ), - from: videoPlayerControllerProvider, - name: r'videoPlayerControllerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$videoPlayerControllerHash, - dependencies: VideoPlayerControllerFamily._dependencies, - allTransitiveDependencies: - VideoPlayerControllerFamily._allTransitiveDependencies, - asset: asset, - ); - - VideoPlayerControllerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.asset, - }) : super.internal(); - - final Asset asset; - - @override - Override overrideWith( - FutureOr Function(VideoPlayerControllerRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: VideoPlayerControllerProvider._internal( - (ref) => create(ref as VideoPlayerControllerRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - asset: asset, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _VideoPlayerControllerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is VideoPlayerControllerProvider && other.asset == asset; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, asset.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin VideoPlayerControllerRef - on AutoDisposeFutureProviderRef { - /// The parameter `asset` of this provider. - Asset get asset; -} - -class _VideoPlayerControllerProviderElement - extends AutoDisposeFutureProviderElement - with VideoPlayerControllerRef { - _VideoPlayerControllerProviderElement(super.provider); - - @override - Asset get asset => (origin as VideoPlayerControllerProvider).asset; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index d15b26ea20..69be91480f 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -1,15 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; class VideoPlaybackControls { - VideoPlaybackControls({ + const VideoPlaybackControls({ required this.position, - required this.mute, required this.pause, + this.restarted = false, }); final double position; - final bool mute; final bool pause; + final bool restarted; } final videoPlayerControlsProvider = @@ -17,15 +18,11 @@ final videoPlayerControlsProvider = return VideoPlayerControls(ref); }); +const videoPlayerControlsDefault = + VideoPlaybackControls(position: 0, pause: false); + class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) - : super( - VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ), - ); + VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); final Ref ref; @@ -36,75 +33,48 @@ class VideoPlayerControls extends StateNotifier { } void reset() { - state = VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ); + state = videoPlayerControlsDefault; } double get position => state.position; - bool get mute => state.mute; + bool get paused => state.pause; set position(double value) { - state = VideoPlaybackControls( - position: value, - mute: state.mute, - pause: state.pause, - ); - } + if (state.position == value) { + return; + } - set mute(bool value) { - state = VideoPlaybackControls( - position: state.position, - mute: value, - pause: state.pause, - ); - } - - void toggleMute() { - state = VideoPlaybackControls( - position: state.position, - mute: !state.mute, - pause: state.pause, - ); + state = VideoPlaybackControls(position: value, pause: state.pause); } void pause() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: true, - ); + if (state.pause) { + return; + } + + state = VideoPlaybackControls(position: state.position, pause: true); } void play() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: false, - ); + if (!state.pause) { + return; + } + + state = VideoPlaybackControls(position: state.position, pause: false); } void togglePlay() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: !state.pause, - ); + state = + VideoPlaybackControls(position: state.position, pause: !state.pause); } void restart() { - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: true, - ); - - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: false, - ); + state = + const VideoPlaybackControls(position: 0, pause: false, restarted: true); + ref.read(videoPlaybackValueProvider.notifier).value = + ref.read(videoPlaybackValueProvider.notifier).value.copyWith( + state: VideoPlaybackState.playing, + position: Duration.zero, + ); } } diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index ebdf739ef0..1a3c54e9e9 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -1,5 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:video_player/video_player.dart'; +import 'package:native_video_player/native_video_player.dart'; enum VideoPlaybackState { initializing, @@ -22,56 +22,66 @@ class VideoPlaybackValue { /// The volume of the video final double volume; - VideoPlaybackValue({ + const VideoPlaybackValue({ required this.position, required this.duration, required this.state, required this.volume, }); - factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { - final video = controller?.value; - late VideoPlaybackState s; - if (video == null) { - s = VideoPlaybackState.initializing; - } else if (video.isCompleted) { - s = VideoPlaybackState.completed; - } else if (video.isPlaying) { - s = VideoPlaybackState.playing; - } else if (video.isBuffering) { - s = VideoPlaybackState.buffering; - } else { - s = VideoPlaybackState.paused; + factory VideoPlaybackValue.fromNativeController( + NativeVideoPlayerController controller, + ) { + final playbackInfo = controller.playbackInfo; + final videoInfo = controller.videoInfo; + + if (playbackInfo == null || videoInfo == null) { + return videoPlaybackValueDefault; } + final VideoPlaybackState status = switch (playbackInfo.status) { + PlaybackStatus.playing => VideoPlaybackState.playing, + PlaybackStatus.paused => VideoPlaybackState.paused, + PlaybackStatus.stopped => VideoPlaybackState.completed, + }; + return VideoPlaybackValue( - position: video?.position ?? Duration.zero, - duration: video?.duration ?? Duration.zero, - state: s, - volume: video?.volume ?? 0.0, + position: Duration(seconds: playbackInfo.position), + duration: Duration(seconds: videoInfo.duration), + state: status, + volume: playbackInfo.volume, ); } - factory VideoPlaybackValue.uninitialized() { + VideoPlaybackValue copyWith({ + Duration? position, + Duration? duration, + VideoPlaybackState? state, + double? volume, + }) { return VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, + position: position ?? this.position, + duration: duration ?? this.duration, + state: state ?? this.state, + volume: volume ?? this.volume, ); } } +const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, +); + final videoPlaybackValueProvider = StateNotifierProvider((ref) { return VideoPlaybackValueState(ref); }); class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) - : super( - VideoPlaybackValue.uninitialized(), - ); + VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); final Ref ref; @@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { + if (state.position == value) return; state = VideoPlaybackValue( position: value, duration: state.duration, @@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier { volume: state.volume, ); } + + set status(VideoPlaybackState value) { + if (state.state == value) return; + state = VideoPlaybackValue( + position: state.position, + duration: state.duration, + state: value, + volume: state.volume, + ); + } + + void reset() { + state = videoPlaybackValueDefault; + } } diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index bbfaf12a4f..36fd3334b9 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset class ImmichLocalImageProvider extends ImageProvider { final Asset asset; + // only used for videos + final double width; + final double height; + final Logger log = Logger('ImmichLocalImageProvider'); ImmichLocalImageProvider({ required this.asset, + required this.width, + required this.height, }) : assert(asset.local != null, 'Only usable when asset.local is set'); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - Asset key, + Asset asset, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { - // Load a small thumbnail - final thumbBytes = await asset.local?.thumbnailDataWithSize( - const ThumbnailSize.square(256), - quality: 80, - ); - if (thumbBytes != null) { - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); - final codec = await decode(buffer); - yield codec; - } else { - debugPrint("Loading thumb for ${asset.fileName} failed"); - } - - if (asset.isImage) { - final File? file = await asset.local?.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); + ui.ImmutableBuffer? buffer; + try { + final local = asset.local; + if (local == null) { + throw StateError('Asset ${asset.fileName} has no local data'); } - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield codec; - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); - } - } - chunkEvents.close(); + var thumbBytes = await local + .thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80); + if (thumbBytes == null) { + throw StateError("Loading thumbnail for ${asset.fileName} failed"); + } + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + + switch (asset.type) { + case AssetType.image: + final File? file = await local.originFile; + if (file == null) { + throw StateError("Opening file for asset ${asset.fileName} failed"); + } + buffer = await ui.ImmutableBuffer.fromFilePath(file.path); + yield await decode(buffer); + buffer = null; + break; + case AssetType.video: + final size = ThumbnailSize(width.ceil(), height.ceil()); + thumbBytes = await local.thumbnailDataWithSize(size); + if (thumbBytes == null) { + throw StateError("Failed to load preview for ${asset.fileName}"); + } + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + break; + default: + throw StateError('Unsupported asset type ${asset.type}'); + } + } catch (error, stack) { + log.severe('Error loading local image ${asset.fileName}', error, stack); + buffer?.dispose(); + } finally { + chunkEvents.close(); + } } @override diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b001c6bdd6..785d23a7ad 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; @@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), + AutoRoute( + page: NativeVideoViewerRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index ea7d385e85..48ee4db5fd 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1079,6 +1079,64 @@ class MemoryRouteArgs { } } +/// generated route for +/// [NativeVideoViewerPage] +class NativeVideoViewerRoute extends PageRouteInfo { + NativeVideoViewerRoute({ + Key? key, + required Asset asset, + required Widget image, + bool showControls = true, + List? children, + }) : super( + NativeVideoViewerRoute.name, + args: NativeVideoViewerRouteArgs( + key: key, + asset: asset, + image: image, + showControls: showControls, + ), + initialChildren: children, + ); + + static const String name = 'NativeVideoViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return NativeVideoViewerPage( + key: args.key, + asset: args.asset, + image: args.image, + showControls: args.showControls, + ); + }, + ); +} + +class NativeVideoViewerRouteArgs { + const NativeVideoViewerRouteArgs({ + this.key, + required this.asset, + required this.image, + this.showControls = true, + }); + + final Key? key; + + final Asset asset; + + final Widget image; + + final bool showControls; + + @override + String toString() { + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}'; + } +} + /// generated route for /// [PartnerDetailPage] class PartnerDetailRoute extends PageRouteInfo { diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b2cad4dc82..7d27d1b27b 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -402,4 +403,29 @@ class AssetService { return exifInfo?.description ?? ""; } + + Future getAspectRatio(Asset asset) async { + // platform_manager always returns 0 for orientation on iOS, so only prefer it on Android + if (asset.isLocal && Platform.isAndroid) { + await asset.localAsync; + } else if (asset.isRemote) { + asset = await loadExif(asset); + } else if (asset.isLocal) { + await asset.localAsync; + } + + final aspectRatio = asset.aspectRatio; + if (aspectRatio != null) { + return aspectRatio; + } + + final width = asset.width; + final height = asset.height; + if (width != null && height != null) { + // we don't know the orientation, so assume it's normal + return width / height; + } + + return 1.0; + } } diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart index ca5f8fc2be..78870151a6 100644 --- a/mobile/lib/utils/debounce.dart +++ b/mobile/lib/utils/debounce.dart @@ -3,20 +3,52 @@ import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; /// Used to debounce function calls with the [interval] provided. +/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied. class Debouncer { - Debouncer({required this.interval}); + Debouncer({required this.interval, this.maxWaitTime}); final Duration interval; + final Duration? maxWaitTime; Timer? _timer; FutureOr Function()? _lastAction; + DateTime? _lastActionTime; + Future? _actionFuture; void run(FutureOr Function() action) { _lastAction = action; _timer?.cancel(); + + if (maxWaitTime != null && + // _actionFuture == null && // TODO: should this check be here? + (_lastActionTime == null || + DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) { + _callAndRest(); + return; + } _timer = Timer(interval, _callAndRest); } + Future? drain() { + if (_timer != null && _timer!.isActive) { + _timer!.cancel(); + if (_lastAction != null) { + _callAndRest(); + } + } + return _actionFuture; + } + + @pragma('vm:prefer-inline') void _callAndRest() { - _lastAction?.call(); + _lastActionTime = DateTime.now(); + final action = _lastAction; + _lastAction = null; + + final result = action!(); + if (result is Future) { + _actionFuture = result.whenComplete(() { + _actionFuture = null; + }); + } _timer = null; } @@ -24,31 +56,48 @@ class Debouncer { _timer?.cancel(); _timer = null; _lastAction = null; + _lastActionTime = null; + _actionFuture = null; } + + bool get isActive => + _actionFuture != null || (_timer != null && _timer!.isActive); } /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a /// default interval of 300ms is used to debounce the function calls Debouncer useDebouncer({ Duration interval = const Duration(milliseconds: 300), + Duration? maxWaitTime, List? keys, }) => - use(_DebouncerHook(interval: interval, keys: keys)); + use( + _DebouncerHook( + interval: interval, + maxWaitTime: maxWaitTime, + keys: keys, + ), + ); class _DebouncerHook extends Hook { const _DebouncerHook({ required this.interval, + this.maxWaitTime, super.keys, }); final Duration interval; + final Duration? maxWaitTime; @override HookState> createState() => _DebouncerHookState(); } class _DebouncerHookState extends HookState { - late final debouncer = Debouncer(interval: hook.interval); + late final debouncer = Debouncer( + interval: hook.interval, + maxWaitTime: hook.maxWaitTime, + ); @override Debouncer build(_) => debouncer; diff --git a/mobile/lib/utils/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart deleted file mode 100644 index 2868e896cf..0000000000 --- a/mobile/lib/utils/hooks/chewiew_controller_hook.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:video_player/video_player.dart'; - -/// Provides the initialized video player controller -/// If the asset is local, use the local file -/// Otherwise, use a video player with a URL -ChewieController useChewieController({ - required VideoPlayerController controller, - EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - bool showOptions = true, - bool showControlsOnInitialize = false, - bool autoPlay = true, - bool allowFullScreen = false, - bool allowedScreenSleep = false, - bool showControls = true, - bool loopVideo = false, - Widget? customControls, - Widget? placeholder, - Duration hideControlsTimer = const Duration(seconds: 1), - VoidCallback? onPlaying, - VoidCallback? onPaused, - VoidCallback? onVideoEnded, -}) { - return use( - _ChewieControllerHook( - controller: controller, - placeholder: placeholder, - showOptions: showOptions, - controlsSafeAreaMinimum: controlsSafeAreaMinimum, - autoPlay: autoPlay, - allowFullScreen: allowFullScreen, - customControls: customControls, - hideControlsTimer: hideControlsTimer, - showControlsOnInitialize: showControlsOnInitialize, - showControls: showControls, - loopVideo: loopVideo, - allowedScreenSleep: allowedScreenSleep, - onPlaying: onPlaying, - onPaused: onPaused, - onVideoEnded: onVideoEnded, - ), - ); -} - -class _ChewieControllerHook extends Hook { - final VideoPlayerController controller; - final EdgeInsets controlsSafeAreaMinimum; - final bool showOptions; - final bool showControlsOnInitialize; - final bool autoPlay; - final bool allowFullScreen; - final bool allowedScreenSleep; - final bool showControls; - final bool loopVideo; - final Widget? customControls; - final Widget? placeholder; - final Duration hideControlsTimer; - final VoidCallback? onPlaying; - final VoidCallback? onPaused; - final VoidCallback? onVideoEnded; - - const _ChewieControllerHook({ - required this.controller, - this.controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - this.showOptions = true, - this.showControlsOnInitialize = false, - this.autoPlay = true, - this.allowFullScreen = false, - this.allowedScreenSleep = false, - this.showControls = true, - this.loopVideo = false, - this.customControls, - this.placeholder, - this.hideControlsTimer = const Duration(seconds: 3), - this.onPlaying, - this.onPaused, - this.onVideoEnded, - }); - - @override - createState() => _ChewieControllerHookState(); -} - -class _ChewieControllerHookState - extends HookState { - late ChewieController chewieController = ChewieController( - videoPlayerController: hook.controller, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - looping: hook.loopVideo, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - - @override - void dispose() { - chewieController.dispose(); - super.dispose(); - } - - @override - ChewieController build(BuildContext context) { - return chewieController; - } - - /* - /// Initializes the chewie controller and video player controller - Future _initialize() async { - if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await hook.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - videoPlayerController = VideoPlayerController.file(file); - } else { - // Use a network URL for the video player controller - final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); - final String videoUrl = hook.asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback'; - - final url = Uri.parse(videoUrl); - final accessToken = store.Store.get(StoreKey.accessToken); - - videoPlayerController = VideoPlayerController.networkUrl( - url, - httpHeaders: {"x-immich-user-token": accessToken}, - ); - } - - await videoPlayerController!.initialize(); - - chewieController = ChewieController( - videoPlayerController: videoPlayerController!, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - } - */ -} diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart new file mode 100644 index 0000000000..0c346065f7 --- /dev/null +++ b/mobile/lib/utils/hooks/interval_hook.dart @@ -0,0 +1,18 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter_hooks/flutter_hooks.dart'; + +// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 +void useInterval(Duration delay, VoidCallback callback) { + final savedCallback = useRef(callback); + savedCallback.value = callback; + + useEffect( + () { + final timer = Timer.periodic(delay, (_) => savedCallback.value()); + return timer.cancel; + }, + [delay], + ); +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 2b02a5ff8f..67ff060075 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; -const int targetVersion = 6; +const int targetVersion = 7; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, 1); diff --git a/mobile/lib/utils/throttle.dart b/mobile/lib/utils/throttle.dart index 9a54e01fc1..bc0dcf9e2f 100644 --- a/mobile/lib/utils/throttle.dart +++ b/mobile/lib/utils/throttle.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_hooks/flutter_hooks.dart'; /// Throttles function calls with the [interval] provided. @@ -10,12 +8,15 @@ class Throttler { Throttler({required this.interval}); - void run(FutureOr Function() action) { + T? run(T Function() action) { if (_lastActionTime == null || (DateTime.now().difference(_lastActionTime!) > interval)) { - action(); + final response = action(); _lastActionTime = DateTime.now(); + return response; } + + return null; } void dispose() { diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 38e499b5de..5670aa388f 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; @@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState { ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); + late final KeepAliveLink currentAssetLink; /// The timestamp when the haptic feedback was last invoked int _hapticFeedbackTS = 0; @@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState { allAssetsSelected: _allAssetsSelected, showStack: widget.showStack, heroOffset: widget.heroOffset, + onAssetTap: (asset) { + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } + }, ); } @@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState { @override void initState() { super.initState(); + currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive(); scrollToTopNotifierProvider.addListener(_scrollToTop); scrollToDateNotifierProvider.addListener(_scrollToDate); @@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState { _itemPositionsListener.itemPositions.removeListener(_positionListener); } _itemPositionsListener.itemPositions.removeListener(_hapticsListener); + currentAssetLink.close(); super.dispose(); } @@ -595,12 +606,13 @@ class _Section extends StatelessWidget { final RenderList renderList; final bool selectionActive; final bool dynamicLayout; - final Function(List) selectAssets; - final Function(List) deselectAssets; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; final bool Function(List) allAssetsSelected; final bool showStack; final int heroOffset; final bool showStorageIndicator; + final void Function(Asset) onAssetTap; const _Section({ required this.section, @@ -618,6 +630,7 @@ class _Section extends StatelessWidget { required this.showStack, required this.heroOffset, required this.showStorageIndicator, + required this.onAssetTap, }); @override @@ -683,6 +696,7 @@ class _Section extends StatelessWidget { selectionActive: selectionActive, onSelect: (asset) => selectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]), + onAssetTap: onAssetTap, ), ], ); @@ -724,9 +738,9 @@ class _Title extends StatelessWidget { final String title; final List assets; final bool selectionActive; - final Function(List) selectAssets; - final Function(List) deselectAssets; - final Function(List) allAssetsSelected; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; + final bool Function(List) allAssetsSelected; const _Title({ required this.title, @@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget { final bool showStorageIndicator; final int heroOffset; final bool showStack; - final Function(Asset)? onSelect; - final Function(Asset)? onDeselect; + final void Function(Asset) onAssetTap; + final void Function(Asset)? onSelect; + final void Function(Asset)? onDeselect; final bool isSelectionActive; const _AssetRow({ @@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget { required this.showStack, required this.isSelectionActive, required this.selectedAssets, + required this.onAssetTap, this.onSelect, this.onDeselect, }); @@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget { onSelect?.call(asset); } } else { + final asset = renderList.loadAsset(absoluteOffset + index); + onAssetTap(asset); context.pushRoute( GalleryViewerRoute( renderList: renderList, diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 82ca295d8a..256141dc7d 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -5,11 +5,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; @@ -26,12 +26,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; class BottomGalleryBar extends ConsumerWidget { - final Asset asset; final ValueNotifier assetIndex; final bool showStack; - final int stackIndex; + final ValueNotifier stackIndex; final ValueNotifier totalAssets; - final bool showVideoPlayerControls; final PageController controller; final RenderList renderList; @@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget { super.key, required this.showStack, required this.stackIndex, - required this.asset, required this.assetIndex, required this.controller, required this.totalAssets, - required this.showVideoPlayerControls, required this.renderList, }); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); + final stackId = asset.stackId; - final stackItems = showStack && asset.stackCount > 0 - ? ref.watch(assetStackStateProvider(asset)) + final stackItems = showStack && stackId != null + ? ref.watch(assetStackStateProvider(stackId)) : []; bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; @@ -64,10 +66,10 @@ class BottomGalleryBar extends ConsumerWidget { final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false; void removeAssetFromStack() { - if (stackIndex > 0 && showStack) { + if (stackIndex.value > 0 && showStack && stackId != null) { ref - .read(assetStackStateProvider(asset).notifier) - .removeChild(stackIndex - 1); + .read(assetStackStateProvider(stackId).notifier) + .removeChild(stackIndex.value - 1); } } @@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget { await ref .read(stackServiceProvider) - .deleteStack(asset.stackId!, [asset, ...stackItems]); + .deleteStack(asset.stackId!, stackItems); } void showStackActionItems() { @@ -324,16 +326,16 @@ class BottomGalleryBar extends ConsumerWidget { }, ]; return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [blackOpacity90, Colors.transparent], + colors: [Colors.black, Colors.transparent], ), ), position: DecorationPosition.background, @@ -341,7 +343,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ - if (showVideoPlayerControls) const VideoControls(), + if (asset.isVideo) const VideoControls(), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index a34fcb9baf..d759b0d80b 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,38 +1,48 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { final Duration hideTimerDuration; const CustomVideoPlayerControls({ super.key, - this.hideTimerDuration = const Duration(seconds: 3), + this.hideTimerDuration = const Duration(seconds: 5), }); @override Widget build(BuildContext context, WidgetRef ref) { + final assetIsVideo = ref.watch( + currentAssetProvider.select((asset) => asset != null && asset.isVideo), + ); + final showControls = ref.watch(showControlsProvider); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, () { + if (!context.mounted) { + return; + } final state = ref.read(videoPlaybackValueProvider).state; + // Do not hide on paused - if (state != VideoPlaybackState.paused) { + if (state != VideoPlaybackState.paused && + state != VideoPlaybackState.completed && + assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }, ); - - final showBuffering = useState(false); - final VideoPlaybackState state = - ref.watch(videoPlaybackValueProvider).state; + final showBuffering = state == VideoPlaybackState.buffering; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -40,28 +50,15 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we mute, show the controls - ref.listen(videoPlayerControlsProvider.select((v) => v.mute), - (previous, next) { - showControlsAndStartHideTimer(); - }); - // When we change position, show or hide timer ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { showControlsAndStartHideTimer(); }); - ref.listen(videoPlaybackValueProvider.select((value) => value.state), - (_, state) { - // Show buffering - showBuffering.value = state == VideoPlaybackState.buffering; - }); - /// Toggles between playing and pausing depending on the state of the video void togglePlay() { showControlsAndStartHideTimer(); - final state = ref.read(videoPlaybackValueProvider).state; if (state == VideoPlaybackState.playing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } else if (state == VideoPlaybackState.completed) { @@ -75,10 +72,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget { behavior: HitTestBehavior.opaque, onTap: showControlsAndStartHideTimer, child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), + absorbing: !showControls, child: Stack( children: [ - if (showBuffering.value) + if (showBuffering) const Center( child: DelayedLoadingIndicator( fadeInDuration: Duration(milliseconds: 400), @@ -86,18 +83,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ) else GestureDetector( - onTap: () { - if (state != VideoPlaybackState.playing) { - togglePlay(); - } - ref.read(showControlsProvider.notifier).show = false; - }, + onTap: () => + ref.read(showControlsProvider.notifier).show = false, child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, isPlaying: state == VideoPlaybackState.playing, - show: ref.watch(showControlsProvider), + show: assetIsVideo && showControls, onPressed: togglePlay, ), ), diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 3c650bdc6a..0dd3305302 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget { Widget build(BuildContext context) { final textColor = context.isDarkTheme ? Colors.white : Colors.black; - String resolution = asset.width != null && asset.height != null - ? "${asset.height} x ${asset.width} " - : ""; + final height = asset.orientatedHeight ?? asset.height; + final width = asset.orientatedWidth ?? asset.width; + String resolution = + height != null && width != null ? "$height x $width " : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index f400224e0a..f7e2158ea9 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -19,23 +20,19 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class GalleryAppBar extends ConsumerWidget { - final Asset asset; final void Function() showInfo; - final void Function() onToggleMotionVideo; - final bool isPlayingVideo; - const GalleryAppBar({ - super.key, - required this.asset, - required this.showInfo, - required this.onToggleMotionVideo, - required this.isPlayingVideo, - }); + const GalleryAppBar({super.key, required this.showInfo}); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final album = ref.watch(currentAlbumProvider); final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); final isPartner = ref .watch(partnerSharedWithProvider) @@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget { } return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: Container( color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, - isPlayingMotionVideo: isPlayingVideo, asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, - onToggleMotionVideo: onToggleMotionVideo, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, ), diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart new file mode 100644 index 0000000000..e4dd355554 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoButton extends ConsumerWidget { + const MotionPhotoButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + + return IconButton( + onPressed: () { + ref.read(isPlayingMotionVideoProvider.notifier).toggle(); + }, + icon: isPlaying + ? const Icon(Icons.motion_photos_pause_outlined, color: grey200) + : const Icon(Icons.play_circle_outline_rounded, color: grey200), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 984b61f50c..2bdbb72ec0 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; class TopControlAppBar extends HookConsumerWidget { const TopControlAppBar({ @@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget { required this.onDownloadPressed, required this.onAddToAlbumPressed, required this.onRestorePressed, - required this.onToggleMotionVideo, - required this.isPlayingMotionVideo, required this.onFavorite, required this.onUploadPressed, required this.isOwner, @@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget { final Function onMoreInfoPressed; final VoidCallback? onUploadPressed; final VoidCallback? onDownloadPressed; - final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; final VoidCallback onRestorePressed; final VoidCallback onActivitiesPressed; final Function(Asset) onFavorite; - final bool isPlayingMotionVideo; final bool isOwner; final bool isPartner; @@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget { ); } - Widget buildLivePhotoButton() { - return IconButton( - onPressed: () { - onToggleMotionVideo(); - }, - icon: isPlayingMotionVideo - ? Icon( - Icons.motion_photos_pause_outlined, - color: Colors.grey[200], - ) - : Icon( - Icons.play_circle_outline_rounded, - color: Colors.grey[200], - ), - ); - } - Widget buildMoreInfoButton() { return IconButton( onPressed: () { @@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget { foregroundColor: Colors.grey[100], backgroundColor: Colors.transparent, leading: buildBackButton(), - actionsIconTheme: const IconThemeData( - size: iconSize, - ), + actionsIconTheme: const IconThemeData(size: iconSize), shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (asset.livePhotoVideoId != null) buildLivePhotoButton(), + if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart deleted file mode 100644 index ebf158b59a..0000000000 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerViewer extends HookConsumerWidget { - final VideoPlayerController controller; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoPlayerViewer({ - super.key, - required this.controller, - required this.isMotionVideo, - this.placeholder, - required this.hideControlsTimer, - required this.showControls, - required this.showDownloadingIndicator, - required this.loopVideo, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final chewie = useChewieController( - controller: controller, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: SizedBox.expand(child: placeholder), - customControls: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - loopVideo: loopVideo, - ); - - return Chewie( - controller: chewie, - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index ef309b9c85..b1f70b8686 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget { ref.read(videoPlayerControlsProvider.notifier).play(); } }, - onChanged: (position) { + onChanged: (value) { + final inSeconds = + (duration * (value / 100.0)).inSeconds; + final position = inSeconds.toDouble(); ref .read(videoPlayerControlsProvider.notifier) .position = position; + // This immediately updates the slider position without waiting for the video to update + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: inSeconds); }, ), ), diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 5946dee453..ab0f2584b5 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget { // either by using the asset ID or the asset itself /// [asset] is the Asset to request, or else use [assetId] to get a remote /// image provider - /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail - /// The size of the square thumbnail to request. Ignored if isThumbnail - /// is not true static ImageProvider imageProvider({ Asset? asset, String? assetId, + double width = 1080, + double height = 1920, }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); @@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget { if (useLocal(asset)) { return ImmichLocalImageProvider( asset: asset, + width: width, + height: height, ); } else { return ImmichRemoteImageProvider( @@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget { }, image: ImmichImage.imageProvider( asset: asset, + width: context.width, + height: context.height, ), width: width, height: height, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index fb7cc882a0..4954d0bfcc 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -2,9 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; @@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: VideoViewerPage( - key: ValueKey(asset), - asset: asset, - showDownloadingIndicator: false, - placeholder: SizedBox.expand( - child: ImmichImage( + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: ValueKey(asset.id), + asset: asset, + showControls: false, + image: ImmichImage( asset, + width: context.width, + height: context.height, fit: fit, ), ), - hideControlsTimer: const Duration(seconds: 2), - showControls: false, ), ); } @@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget { image: DecorationImage( image: ImmichImage.imageProvider( asset: asset, + height: context.height, + width: context.width, ), fit: BoxFit.cover, ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9dc53e42b9..9203dcdf82 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -214,14 +214,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - chewie: - dependency: "direct main" - description: - name: chewie - sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879" - url: "https://pub.dev" - source: hosted - version: "1.8.3" ci: dependency: transitive description: @@ -318,14 +310,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -378,10 +362,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091 + sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "11.1.1" device_info_plus_platform_interface: dependency: transitive description: @@ -450,10 +434,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.1.3" file_selector_linux: dependency: transitive description: @@ -548,10 +532,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "17.2.1+2" flutter_local_notifications_linux: dependency: transitive description: @@ -1024,14 +1008,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - nested: - dependency: transitive + native_video_player: + dependency: "direct main" description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + path: "." + ref: ac78487 + resolved-ref: ac78487b9a87c9e72cd15b428270a905ac551f29 + url: "https://github.com/immich-app/native_video_player" + source: git + version: "1.3.1" nm: dependency: transitive description: @@ -1067,10 +1052,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef" + sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.1.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1255,14 +1240,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" pub_semver: dependency: transitive description: @@ -1339,10 +1316,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11 + sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" url: "https://pub.dev" source: hosted - version: "10.0.3" + version: "10.1.2" share_plus_platform_interface: dependency: transitive description: @@ -1708,46 +1685,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - video_player: - dependency: "direct main" - description: - name: video_player - sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" - url: "https://pub.dev" - source: hosted - version: "2.9.2" - video_player_android: - dependency: "direct main" - description: - name: video_player_android - sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c - url: "https://pub.dev" - source: hosted - version: "2.6.1" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - video_player_web: - dependency: transitive - description: - name: video_player_web - sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774" - url: "https://pub.dev" - source: hosted - version: "2.3.2" vm_service: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 235c58ce63..a037f9b947 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,9 +25,6 @@ dependencies: intl: ^0.19.0 auto_route: ^9.2.0 fluttertoast: ^8.2.4 - video_player: ^2.9.2 - video_player_android: 2.6.0 - chewie: ^1.7.4 socket_io_client: ^2.0.3+1 maplibre_gl: 0.19.0+2 geolocator: ^11.0.0 # used to move to current location in map view @@ -45,7 +42,7 @@ dependencies: path_provider: ^2.1.2 collection: ^1.18.0 http_parser: ^4.0.2 - flutter_web_auth: ^0.6.0 + flutter_web_auth: 0.6.0 easy_image_viewer: ^1.4.0 isar: version: *isar_version @@ -64,6 +61,10 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 + native_video_player: + git: + url: https://github.com/immich-app/native_video_player + ref: ac78487 #image editing packages crop_image: ^1.0.13 From 055f1fc72fd1b3cecd00a5110e1f830a71a41945 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Dec 2024 09:11:48 -0600 Subject: [PATCH 512/599] feat(mobile): Auto switching server URLs (#14437) --- mobile/analysis_options.yaml | 2 + .../android/app/src/main/AndroidManifest.xml | 2 + mobile/assets/i18n/en-US.json | 38 ++- mobile/ios/Podfile | 7 + mobile/ios/Podfile.lock | 8 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +- mobile/ios/Runner/AppDelegate.swift | 22 +- mobile/ios/Runner/Info.plist | 6 +- mobile/ios/Runner/Runner.entitlements | 5 +- mobile/ios/Runner/RunnerProfile.entitlements | 2 + mobile/lib/entities/store.entity.dart | 6 + .../extensions/build_context_extensions.dart | 4 + mobile/lib/interfaces/auth.interface.dart | 6 + mobile/lib/interfaces/network.interface.dart | 4 + .../models/auth/auxilary_endpoint.model.dart | 105 +++++++ mobile/lib/pages/common/settings.page.dart | 113 ++++++-- .../lib/pages/common/splash_screen.page.dart | 106 ++++--- .../providers/app_life_cycle.provider.dart | 40 ++- mobile/lib/providers/auth.provider.dart | 41 +++ mobile/lib/providers/network.provider.dart | 38 +++ .../lib/providers/server_info.provider.dart | 2 +- mobile/lib/repositories/auth.repository.dart | 39 +++ .../lib/repositories/network.repository.dart | 37 +++ .../repositories/permission.repository.dart | 45 +++ mobile/lib/services/api.service.dart | 4 +- mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/auth.service.dart | 93 ++++++ mobile/lib/services/background.service.dart | 26 ++ mobile/lib/services/network.service.dart | 47 ++++ .../networking_settings/endpoint_input.dart | 155 ++++++++++ .../external_network_preference.dart | 189 +++++++++++++ .../local_network_preference.dart | 256 +++++++++++++++++ .../networking_settings.dart | 266 ++++++++++++++++++ mobile/openapi/devtools_options.yaml | 3 + mobile/pubspec.lock | 16 ++ mobile/pubspec.yaml | 1 + mobile/test/service.mocks.dart | 3 + mobile/test/services/auth.service_test.dart | 192 ++++++++++++- 38 files changed, 1828 insertions(+), 108 deletions(-) create mode 100644 mobile/lib/interfaces/network.interface.dart create mode 100644 mobile/lib/models/auth/auxilary_endpoint.model.dart create mode 100644 mobile/lib/providers/network.provider.dart create mode 100644 mobile/lib/repositories/network.repository.dart create mode 100644 mobile/lib/repositories/permission.repository.dart create mode 100644 mobile/lib/services/network.service.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/endpoint_input.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/external_network_preference.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/local_network_preference.dart create mode 100644 mobile/lib/widgets/settings/networking_settings/networking_settings.dart create mode 100644 mobile/openapi/devtools_options.yaml diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2b4b810f2a..9cb03f6758 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -104,6 +104,8 @@ custom_lint: - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart + - lib/services/auth.service.dart # on ApiException + - test/services/auth.service_test.dart # on ApiException dart_code_metrics: metrics: diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 8f239015dd..bbc562c103 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,8 @@ + + diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index d588507a07..121e3e4982 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,35 @@ { + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", + "current_server_address": "Current server address", + "grant_permission": "Grant permission", + "automatic_endpoint_switching_title": "Automatic URL switching", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", + "cancel": "Cancel", + "save": "Save", + "wifi_name": "WiFi Name", + "enter_wifi_name": "Enter WiFi name", + "your_wifi_name": "Your WiFi name", + "server_endpoint": "Server Endpoint", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "use_current_connection": "use current connection", + "add_endpoint": "Add endpoint", + "validate_endpoint_error": "Please enter a valid URL", + "advanced_settings_tile_subtitle": "Manage advanced settings", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "backup_setting_subtitle": "Manage background and foreground upload settings", + "setting_languages_subtitle": "Change the app's language", + "setting_notifications_subtitle": "Manage your notification settings", + "preferences_settings_subtitle": "Manage the app's preferences", + "asset_list_settings_subtitle": "Manage the look of the timeline", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", @@ -16,7 +47,6 @@ "advanced_settings_proxy_headers_title": "Proxy Headers", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", - "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", @@ -56,7 +86,6 @@ "asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month_day": "Month + day", "asset_list_layout_sub_title": "Layout", - "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", "assets_deleted_permanently": "{} asset(s) deleted permanently", @@ -65,7 +94,7 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Asset Viewer", + "asset_viewer_settings_title": "Gallery Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -492,7 +521,6 @@ "setting_notifications_notify_seconds": "{} seconds", "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", "setting_notifications_single_progress_title": "Show background backup detail progress", - "setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_title": "Notifications", "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", @@ -625,4 +653,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index f38ac9619b..b048c0bb0c 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -102,6 +102,13 @@ post_install do |installer| ## dart: PermissionGroup.criticalAlerts # 'PERMISSION_CRITICAL_ALERTS=1' + + ## The 'PERMISSION_LOCATION' macro enables the `locationWhenInUse` and `locationAlways` permission. If + ## the application only requires `locationWhenInUse`, only specify the `PERMISSION_LOCATION_WHENINUSE` + ## macro. + ## + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=1', ] end diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 2e71937a84..bc65bd4b7f 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -67,6 +67,8 @@ PODS: - MapLibre (= 5.14.0-pre3) - native_video_player (1.0.0): - Flutter + - network_info_plus (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -115,6 +117,7 @@ DEPENDENCIES: - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) + - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) @@ -169,6 +172,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/maplibre_gl/ios" native_video_player: :path: ".symlinks/plugins/native_video_player/ios" + network_info_plus: + :path: ".symlinks/plugins/network_info_plus/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -210,6 +215,7 @@ SPEC CHECKSUMS: MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c + network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 @@ -225,6 +231,6 @@ SPEC CHECKSUMS: url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 -PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d +PODFILE CHECKSUM: 2282844f7aed70427ae663932332dad1225156c8 COCOAPODS: 1.15.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 45d0b7d0ef..49ac6c4cff 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -126,6 +127,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FA9973382CF6DF4B000EF859 /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -541,6 +543,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; @@ -553,7 +556,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.121.0; - PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.debug; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug; PRODUCT_NAME = "Immich-Debug"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -569,6 +572,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 446c82e78f..8f635bc61b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,19 +1,18 @@ import BackgroundTasks import Flutter -import UIKit +import network_info_plus import path_provider_ios import permission_handler_apple import photo_manager import shared_preferences_foundation +import UIKit @main @objc class AppDelegate: FlutterAppDelegate { - override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Required for flutter_local_notification if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate @@ -33,27 +32,26 @@ import shared_preferences_foundation BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) } if !registry.hasPlugin("org.cocoapods.photo-manager") { - PhotoManagerPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) + PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) } if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { - SharedPreferencesPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) } if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { - PermissionHandlerPlugin.register( - with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } + + if !registry.hasPlugin("org.cocoapods.network-info-plus") { + FPPNetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.network-info-plus")!) } } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - } diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index f4ded26c68..4389b39114 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -82,8 +82,12 @@ NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app + NSLocationAlwaysAndWhenInUseUsageDescription + We require this permission to access the local WiFi name for background upload mechanism + NSLocationUsageDescription + We require this permission to access the local WiFi name NSLocationWhenInUseUsageDescription - Enable location setting to show position of assets on map + We require this permission to access the local WiFi name NSMicrophoneUsageDescription We need to access the microphone to let you take beautiful video using this app NSPhotoLibraryAddUsageDescription diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 0c67376eba..ba21fbdaf2 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -1,5 +1,8 @@ - + + com.apple.developer.networking.wifi-info + + diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 903def2af5..75e36a143e 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -4,5 +4,7 @@ aps-environment development + com.apple.developer.networking.wifi-info + diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index 1dda2b9a12..316859b064 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -236,6 +236,12 @@ enum StoreKey { colorfulInterface(130, type: bool), syncAlbums(131, type: bool), + + // Auto endpoint switching + autoEndpointSwitching(132, type: bool), + preferredWifiName(133, type: String), + localEndpoint(134, type: String), + externalEndpointList(135, type: String), ; const StoreKey( diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index d87ab2845f..69a9c3b347 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -54,4 +54,8 @@ extension ContextHelper on BuildContext { // Managing focus within the widget tree from the current context FocusScopeNode get focusScope => FocusScope.of(this); + + // Show SnackBars from the current context + void showSnackBar(SnackBar snackBar) => + ScaffoldMessenger.of(this).showSnackBar(snackBar); } diff --git a/mobile/lib/interfaces/auth.interface.dart b/mobile/lib/interfaces/auth.interface.dart index e37323b994..57088f4569 100644 --- a/mobile/lib/interfaces/auth.interface.dart +++ b/mobile/lib/interfaces/auth.interface.dart @@ -1,5 +1,11 @@ import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; abstract interface class IAuthRepository implements IDatabaseRepository { Future clearLocalData(); + String getAccessToken(); + bool getEndpointSwitchingFeature(); + String? getPreferredWifiName(); + String? getLocalEndpoint(); + List getExternalEndpointList(); } diff --git a/mobile/lib/interfaces/network.interface.dart b/mobile/lib/interfaces/network.interface.dart new file mode 100644 index 0000000000..098d67a27b --- /dev/null +++ b/mobile/lib/interfaces/network.interface.dart @@ -0,0 +1,4 @@ +abstract interface class INetworkRepository { + Future getWifiName(); + Future getWifiIp(); +} diff --git a/mobile/lib/models/auth/auxilary_endpoint.model.dart b/mobile/lib/models/auth/auxilary_endpoint.model.dart new file mode 100644 index 0000000000..89aba60913 --- /dev/null +++ b/mobile/lib/models/auth/auxilary_endpoint.model.dart @@ -0,0 +1,105 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +class AuxilaryEndpoint { + final String url; + final AuxCheckStatus status; + + AuxilaryEndpoint({ + required this.url, + required this.status, + }); + + AuxilaryEndpoint copyWith({ + String? url, + AuxCheckStatus? status, + }) { + return AuxilaryEndpoint( + url: url ?? this.url, + status: status ?? this.status, + ); + } + + @override + String toString() => 'AuxilaryEndpoint(url: $url, status: $status)'; + + @override + bool operator ==(covariant AuxilaryEndpoint other) { + if (identical(this, other)) return true; + + return other.url == url && other.status == status; + } + + @override + int get hashCode => url.hashCode ^ status.hashCode; + + Map toMap() { + return { + 'url': url, + 'status': status.toMap(), + }; + } + + factory AuxilaryEndpoint.fromMap(Map map) { + return AuxilaryEndpoint( + url: map['url'] as String, + status: AuxCheckStatus.fromMap(map['status'] as Map), + ); + } + + String toJson() => json.encode(toMap()); + + factory AuxilaryEndpoint.fromJson(String source) => + AuxilaryEndpoint.fromMap(json.decode(source) as Map); +} + +class AuxCheckStatus { + final String name; + AuxCheckStatus({ + required this.name, + }); + const AuxCheckStatus._(this.name); + + static const loading = AuxCheckStatus._('loading'); + static const valid = AuxCheckStatus._('valid'); + static const error = AuxCheckStatus._('error'); + static const unknown = AuxCheckStatus._('unknown'); + + @override + bool operator ==(covariant AuxCheckStatus other) { + if (identical(this, other)) return true; + + return other.name == name; + } + + @override + int get hashCode => name.hashCode; + + AuxCheckStatus copyWith({ + String? name, + }) { + return AuxCheckStatus( + name: name ?? this.name, + ); + } + + Map toMap() { + return { + 'name': name, + }; + } + + factory AuxCheckStatus.fromMap(Map map) { + return AuxCheckStatus( + name: map['name'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory AuxCheckStatus.fromJson(String source) => + AuxCheckStatus.fromMap(json.decode(source) as Map); + + @override + String toString() => 'AuxCheckStatus(name: $name)'; +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index a6ca239962..ba3150c046 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -8,36 +8,69 @@ import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_se import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/preference_setting.dart'; import 'package:immich_mobile/routing/router.dart'; enum SettingSection { + advanced( + 'advanced_settings_tile_title', + Icons.build_outlined, + "advanced_settings_tile_subtitle", + ), + assetViewer( + 'asset_viewer_settings_title', + Icons.image_outlined, + "asset_viewer_settings_subtitle", + ), + backup( + 'backup_controller_page_backup', + Icons.cloud_upload_outlined, + "backup_setting_subtitle", + ), + languages( + 'setting_languages_title', + Icons.language, + "setting_languages_subtitle", + ), + networking( + 'networking_settings', + Icons.wifi, + "networking_subtitle", + ), notifications( 'setting_notifications_title', Icons.notifications_none_rounded, + "setting_notifications_subtitle", ), - languages('setting_languages_title', Icons.language), - preferences('preferences_settings_title', Icons.interests_outlined), - backup('backup_controller_page_backup', Icons.cloud_upload_outlined), - timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined), - viewer('asset_viewer_settings_title', Icons.image_outlined), - advanced('advanced_settings_tile_title', Icons.build_outlined); + preferences( + 'preferences_settings_title', + Icons.interests_outlined, + "preferences_settings_subtitle", + ), + timeline( + 'asset_list_settings_title', + Icons.auto_awesome_mosaic_outlined, + "asset_list_settings_subtitle", + ); final String title; + final String subtitle; final IconData icon; Widget get widget => switch (this) { - SettingSection.notifications => const NotificationSetting(), - SettingSection.languages => const LanguageSettings(), - SettingSection.preferences => const PreferenceSetting(), - SettingSection.backup => const BackupSettings(), - SettingSection.timeline => const AssetListSettings(), - SettingSection.viewer => const AssetViewerSettings(), SettingSection.advanced => const AdvancedSettings(), + SettingSection.assetViewer => const AssetViewerSettings(), + SettingSection.backup => const BackupSettings(), + SettingSection.languages => const LanguageSettings(), + SettingSection.networking => const NetworkingSettings(), + SettingSection.notifications => const NotificationSetting(), + SettingSection.preferences => const PreferenceSetting(), + SettingSection.timeline => const AssetListSettings(), }; - const SettingSection(this.title, this.icon); + const SettingSection(this.title, this.icon, this.subtitle); } @RoutePage() @@ -61,22 +94,50 @@ class _MobileLayout extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 10.0), children: SettingSection.values .map( - (s) => ListTile( - contentPadding: - const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0), - leading: Icon(s.icon), - title: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - s.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ).tr(), + (setting) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Card( + elevation: 0, + clipBehavior: Clip.antiAlias, + color: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + leading: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.isDarkTheme + ? Colors.black26 + : Colors.white.withAlpha(100), + ), + padding: const EdgeInsets.all(16.0), + child: Icon(setting.icon, color: context.primaryColor), + ), + title: Text( + setting.title, + style: context.textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + subtitle: Text( + setting.subtitle, + ).tr(), + onTap: () => + context.pushRoute(SettingsSubRoute(section: setting)), + ), ), - onTap: () => context.pushRoute(SettingsSubRoute(section: s)), ), ) .toList(), diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index d88c6cf366..6a060e19f0 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; @@ -10,65 +9,80 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; @RoutePage() -class SplashScreenPage extends HookConsumerWidget { +class SplashScreenPage extends StatefulHookConsumerWidget { const SplashScreenPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + SplashScreenPageState createState() => SplashScreenPageState(); +} + +class SplashScreenPageState extends ConsumerState { + final log = Logger("SplashScreenPage"); + @override + void initState() { + super.initState(); + ref + .read(authProvider.notifier) + .setOpenApiServiceEndpoint() + .then(logConnectionInfo) + .whenComplete(() => resumeSession()); + } + + void logConnectionInfo(String? endpoint) { + if (endpoint == null) { + return; + } + + log.info("Resuming session at $endpoint"); + } + + void resumeSession() async { final serverUrl = Store.tryGet(StoreKey.serverUrl); final endpoint = Store.tryGet(StoreKey.serverEndpoint); final accessToken = Store.tryGet(StoreKey.accessToken); - final log = Logger("SplashScreenPage"); - void performLoggingIn() async { - bool isAuthSuccess = false; + bool isAuthSuccess = false; - if (accessToken != null && serverUrl != null && endpoint != null) { - try { - isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( - accessToken: accessToken, - ); - } catch (error, stackTrace) { - log.severe( - 'Cannot set success login info', - error, - stackTrace, - ); - } - } else { - isAuthSuccess = false; + if (accessToken != null && serverUrl != null && endpoint != null) { + try { + isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo( + accessToken: accessToken, + ); + } catch (error, stackTrace) { log.severe( - 'Missing authentication, server, or endpoint info from the local store', + 'Cannot set success login info', + error, + stackTrace, ); } - - if (!isAuthSuccess) { - log.severe( - 'Unable to login using offline or online methods - Logging out completely', - ); - ref.read(authProvider.notifier).logout(); - context.replaceRoute(const LoginRoute()); - return; - } - - context.replaceRoute(const TabControllerRoute()); - - final hasPermission = - await ref.read(galleryPermissionNotifier.notifier).hasPermission; - if (hasPermission) { - // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); - } + } else { + isAuthSuccess = false; + log.severe( + 'Missing authentication, server, or endpoint info from the local store', + ); } - useEffect( - () { - performLoggingIn(); - return null; - }, - [], - ); + if (!isAuthSuccess) { + log.severe( + 'Unable to login using offline or online methods - Logging out completely', + ); + ref.read(authProvider.notifier).logout(); + context.replaceRoute(const LoginRoute()); + return; + } + context.replaceRoute(const TabControllerRoute()); + + final hasPermission = + await ref.read(galleryPermissionNotifier.notifier).hasPermission; + if (hasPermission) { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup(); + } + } + + @override + Widget build(BuildContext context) { return const Scaffold( body: Center( child: Image( diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8cacb70eb2..780e22b818 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -35,7 +36,7 @@ class AppLifeCycleNotifier extends StateNotifier { return state; } - void handleAppResume() { + void handleAppResume() async { state = AppLifeCycleEnum.resumed; // no need to resume because app was never really paused @@ -46,32 +47,49 @@ class AppLifeCycleNotifier extends StateNotifier { // Needs to be logged in if (isAuthenticated) { + // switch endpoint if needed + final endpoint = + await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint(); + if (kDebugMode) { + debugPrint("Using server URL: $endpoint"); + } + final permission = _ref.watch(galleryPermissionNotifier); if (permission.isGranted || permission.isLimited) { - _ref.read(backupProvider.notifier).resumeBackup(); - _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + await _ref.read(backupProvider.notifier).resumeBackup(); + await _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); } - _ref.read(serverInfoProvider.notifier).getServerVersion(); + + await _ref.read(serverInfoProvider.notifier).getServerVersion(); + switch (_ref.read(tabProvider)) { case TabEnum.home: - _ref.read(assetProvider.notifier).getAllAsset(); + await _ref.read(assetProvider.notifier).getAllAsset(); + break; case TabEnum.search: - // nothing to do + // nothing to do + break; + case TabEnum.albums: - _ref.read(albumProvider.notifier).refreshRemoteAlbums(); + await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); + break; case TabEnum.library: - // nothing to do + // nothing to do + break; } } _ref.read(websocketProvider.notifier).connect(); - _ref + await _ref .read(notificationPermissionProvider.notifier) .getNotificationPermission(); - _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); - _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); + await _ref + .read(galleryPermissionNotifier.notifier) + .getGalleryPermissionStatus(); + + await _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); _ref.invalidate(memoryFutureProvider); } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 5efbdab8d3..a23ffd3d68 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -45,6 +45,17 @@ class AuthNotifier extends StateNotifier { return _authService.validateServerUrl(url); } + /// Validating the url is the alternative connecting server url without + /// saving the infomation to the local database + Future validateAuxilaryServerUrl(String url) async { + try { + final validEndpoint = await _apiService.resolveEndpoint(url); + return await _authService.validateAuxilaryServerUrl(validEndpoint); + } catch (_) { + return false; + } + } + Future login(String email, String password) async { final response = await _authService.login(email, password); await saveAuthInfo(accessToken: response.accessToken); @@ -161,4 +172,34 @@ class AuthNotifier extends StateNotifier { return true; } + + Future saveWifiName(String wifiName) { + return Store.put(StoreKey.preferredWifiName, wifiName); + } + + Future saveLocalEndpoint(String url) { + return Store.put(StoreKey.localEndpoint, url); + } + + String? getSavedWifiName() { + return Store.tryGet(StoreKey.preferredWifiName); + } + + String? getSavedLocalEndpoint() { + return Store.tryGet(StoreKey.localEndpoint); + } + + /// Returns the current server endpoint (with /api) URL from the store + String? getServerEndpoint() { + return Store.tryGet(StoreKey.serverEndpoint); + } + + /// Returns the current server URL (input by the user) from the store + String? getServerUrl() { + return Store.tryGet(StoreKey.serverUrl); + } + + Future setOpenApiServiceEndpoint() { + return _authService.setOpenApiServiceEndpoint(); + } } diff --git a/mobile/lib/providers/network.provider.dart b/mobile/lib/providers/network.provider.dart new file mode 100644 index 0000000000..5cb2fae4b1 --- /dev/null +++ b/mobile/lib/providers/network.provider.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/services/network.service.dart'; + +final networkProvider = StateNotifierProvider((ref) { + return NetworkNotifier( + ref.watch(networkServiceProvider), + ); +}); + +class NetworkNotifier extends StateNotifier { + final NetworkService _networkService; + + NetworkNotifier(this._networkService) : super(''); + + Future getWifiName() { + return _networkService.getWifiName(); + } + + Future getWifiReadPermission() { + return _networkService.getLocationWhenInUserPermission(); + } + + Future getWifiReadBackgroundPermission() { + return _networkService.getLocationAlwaysPermission(); + } + + Future requestWifiReadPermission() { + return _networkService.requestLocationWhenInUsePermission(); + } + + Future requestWifiReadBackgroundPermission() { + return _networkService.requestLocationAlwaysPermission(); + } + + Future openSettings() { + return _networkService.openSettings(); + } +} diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 14521b06f6..a793acb3f6 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -59,7 +59,7 @@ class ServerInfoNotifier extends StateNotifier { await getServerConfig(); } - getServerVersion() async { + Future getServerVersion() async { try { final serverVersion = await _serverInfoService.getServerVersion(); diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index ababf35c9b..fa504e6ac3 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -1,10 +1,14 @@ +import 'dart:convert'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; @@ -27,4 +31,39 @@ class AuthRepository extends DatabaseRepository implements IAuthRepository { ]); }); } + + @override + String getAccessToken() { + return Store.get(StoreKey.accessToken); + } + + @override + bool getEndpointSwitchingFeature() { + return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; + } + + @override + String? getPreferredWifiName() { + return Store.tryGet(StoreKey.preferredWifiName); + } + + @override + String? getLocalEndpoint() { + return Store.tryGet(StoreKey.localEndpoint); + } + + @override + List getExternalEndpointList() { + final jsonString = Store.tryGet(StoreKey.externalEndpointList); + + if (jsonString == null) { + return []; + } + + final List jsonList = jsonDecode(jsonString); + final endpointList = + jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList(); + + return endpointList; + } } diff --git a/mobile/lib/repositories/network.repository.dart b/mobile/lib/repositories/network.repository.dart new file mode 100644 index 0000000000..54f527afb1 --- /dev/null +++ b/mobile/lib/repositories/network.repository.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/network.interface.dart'; +import 'package:network_info_plus/network_info_plus.dart'; + +final networkRepositoryProvider = Provider((_) { + final networkInfo = NetworkInfo(); + + return NetworkRepository(networkInfo); +}); + +class NetworkRepository implements INetworkRepository { + final NetworkInfo _networkInfo; + + NetworkRepository(this._networkInfo); + + @override + Future getWifiName() { + if (Platform.isAndroid) { + // remove quote around the return value on Android + // https://github.com/fluttercommunity/plus_plugins/tree/main/packages/network_info_plus/network_info_plus#android + return _networkInfo.getWifiName().then((value) { + if (value != null) { + return value.replaceAll(RegExp(r'"'), ''); + } + return value; + }); + } + return _networkInfo.getWifiName(); + } + + @override + Future getWifiIp() { + return _networkInfo.getWifiIP(); + } +} diff --git a/mobile/lib/repositories/permission.repository.dart b/mobile/lib/repositories/permission.repository.dart new file mode 100644 index 0000000000..f825c36075 --- /dev/null +++ b/mobile/lib/repositories/permission.repository.dart @@ -0,0 +1,45 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +final permissionRepositoryProvider = Provider((_) { + return PermissionRepository(); +}); + +class PermissionRepository implements IPermissionRepository { + PermissionRepository(); + + @override + Future hasLocationWhenInUsePermission() { + return Permission.locationWhenInUse.isGranted; + } + + @override + Future requestLocationWhenInUsePermission() async { + final result = await Permission.locationWhenInUse.request(); + return result.isGranted; + } + + @override + Future hasLocationAlwaysPermission() { + return Permission.locationAlways.isGranted; + } + + @override + Future requestLocationAlwaysPermission() async { + final result = await Permission.locationAlways.request(); + return result.isGranted; + } + + @override + Future openSettings() { + return openAppSettings(); + } +} + +abstract interface class IPermissionRepository { + Future hasLocationWhenInUsePermission(); + Future requestLocationWhenInUsePermission(); + Future hasLocationAlwaysPermission(); + Future requestLocationAlwaysPermission(); + Future openSettings(); +} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 63cd3f9f8c..0f6fe8a100 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -67,7 +67,7 @@ class ApiService implements Authentication { } Future resolveAndSetEndpoint(String serverUrl) async { - final endpoint = await _resolveEndpoint(serverUrl); + final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); // Save in local database for next startup @@ -82,7 +82,7 @@ class ApiService implements Authentication { /// host - required /// port - optional (default: based on schema) /// path - optional - Future _resolveEndpoint(String serverUrl) async { + Future resolveEndpoint(String serverUrl) async { final url = sanitizeUrl(serverUrl); if (!await _isEndpointAvailable(serverUrl)) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8f773e1bb3..14d800a4ef 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -77,6 +77,7 @@ enum AppSettingsEnum { ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), + autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index fa6e282e63..0393470098 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,19 +1,26 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; final authServiceProvider = Provider( (ref) => AuthService( ref.watch(authApiRepositoryProvider), ref.watch(authRepositoryProvider), ref.watch(apiServiceProvider), + ref.watch(networkServiceProvider), ), ); @@ -21,6 +28,7 @@ class AuthService { final IAuthApiRepository _authApiRepository; final IAuthRepository _authRepository; final ApiService _apiService; + final NetworkService _networkService; final _log = Logger("AuthService"); @@ -28,6 +36,7 @@ class AuthService { this._authApiRepository, this._authRepository, this._apiService, + this._networkService, ); /// Validates the provided server URL by resolving and setting the endpoint. @@ -46,6 +55,28 @@ class AuthService { return validUrl; } + Future validateAuxilaryServerUrl(String url) async { + final httpclient = HttpClient(); + final accessToken = _authRepository.getAccessToken(); + bool isValid = false; + + try { + final uri = Uri.parse('$url/users/me'); + final request = await httpclient.getUrl(uri); + request.headers.add('x-immich-user-token', accessToken); + final response = await request.close(); + if (response.statusCode == 200) { + isValid = true; + } + } catch (error) { + _log.severe("Error validating auxilary endpoint", error); + } finally { + httpclient.close(); + } + + return isValid; + } + Future login(String email, String password) { return _authApiRepository.login(email, password); } @@ -84,6 +115,10 @@ class AuthService { Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), Store.delete(StoreKey.assetETag), + Store.delete(StoreKey.autoEndpointSwitching), + Store.delete(StoreKey.preferredWifiName), + Store.delete(StoreKey.localEndpoint), + Store.delete(StoreKey.externalEndpointList), ]); } @@ -95,4 +130,62 @@ class AuthService { rethrow; } } + + Future setOpenApiServiceEndpoint() async { + final enable = _authRepository.getEndpointSwitchingFeature(); + if (!enable) { + return null; + } + + final wifiName = await _networkService.getWifiName(); + final savedWifiName = _authRepository.getPreferredWifiName(); + String? endpoint; + + if (wifiName == savedWifiName) { + endpoint = await _setLocalConnection(); + } + + endpoint ??= await _setRemoteConnection(); + + return endpoint; + } + + Future _setLocalConnection() async { + try { + final localEndpoint = _authRepository.getLocalEndpoint(); + if (localEndpoint != null) { + await _apiService.resolveAndSetEndpoint(localEndpoint); + return localEndpoint; + } + } catch (error, stackTrace) { + _log.severe("Cannot set local endpoint", error, stackTrace); + } + + return null; + } + + Future _setRemoteConnection() async { + List endpointList; + + try { + endpointList = _authRepository.getExternalEndpointList(); + } catch (error, stackTrace) { + _log.severe("Cannot get external endpoint", error, stackTrace); + return null; + } + + for (final endpoint in endpointList) { + try { + return await _apiService.resolveAndSetEndpoint(endpoint.url); + } on ApiException catch (error) { + _log.severe("Cannot resolve endpoint", error); + continue; + } catch (_) { + _log.severe("Auxilary server is not valid"); + continue; + } + } + + return null; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 3959e2a6ed..27be2c046d 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -6,6 +6,7 @@ import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,15 +18,20 @@ import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/repositories/auth.repository.dart'; +import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/network.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -36,11 +42,13 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:network_info_plus/network_info_plus.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -422,6 +430,24 @@ class BackgroundService { assetMediaRepository, ); + AuthApiRepository authApiRepository = AuthApiRepository(apiService); + AuthRepository authRepository = AuthRepository(db); + NetworkRepository networkRepository = NetworkRepository(NetworkInfo()); + PermissionRepository permissionRepository = PermissionRepository(); + NetworkService networkService = + NetworkService(networkRepository, permissionRepository); + AuthService authService = AuthService( + authApiRepository, + authRepository, + apiService, + networkService, + ); + + final endpoint = await authService.setOpenApiServiceEndpoint(); + if (kDebugMode) { + debugPrint("[BG UPLOAD] Using endpoint: $endpoint"); + } + final selectedAlbums = await backupRepository.getAllBySelection(BackupSelection.select); final excludedAlbums = diff --git a/mobile/lib/services/network.service.dart b/mobile/lib/services/network.service.dart new file mode 100644 index 0000000000..f2d2de325d --- /dev/null +++ b/mobile/lib/services/network.service.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/network.interface.dart'; +import 'package:immich_mobile/repositories/network.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; + +final networkServiceProvider = Provider((ref) { + return NetworkService( + ref.watch(networkRepositoryProvider), + ref.watch(permissionRepositoryProvider), + ); +}); + +class NetworkService { + final INetworkRepository _repository; + final IPermissionRepository _permissionRepository; + + NetworkService(this._repository, this._permissionRepository); + + Future getLocationWhenInUserPermission() { + return _permissionRepository.hasLocationWhenInUsePermission(); + } + + Future requestLocationWhenInUsePermission() { + return _permissionRepository.requestLocationWhenInUsePermission(); + } + + Future getLocationAlwaysPermission() { + return _permissionRepository.hasLocationAlwaysPermission(); + } + + Future requestLocationAlwaysPermission() { + return _permissionRepository.requestLocationAlwaysPermission(); + } + + Future getWifiName() async { + final canRead = await getLocationWhenInUserPermission(); + if (!canRead) { + return null; + } + + return await _repository.getWifiName(); + } + + Future openSettings() { + return _permissionRepository.openSettings(); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart new file mode 100644 index 0000000000..6302f9422a --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/endpoint_input.dart @@ -0,0 +1,155 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; + +class EndpointInput extends StatefulHookConsumerWidget { + const EndpointInput({ + super.key, + required this.initialValue, + required this.index, + required this.onValidated, + required this.onDismissed, + this.enabled = true, + }); + + final AuxilaryEndpoint initialValue; + final int index; + final Function(String url, int index, AuxCheckStatus status) onValidated; + final Function(int index) onDismissed; + final bool enabled; + + @override + EndpointInputState createState() => EndpointInputState(); +} + +class EndpointInputState extends ConsumerState { + late final TextEditingController controller; + late final FocusNode focusNode; + late AuxCheckStatus auxCheckStatus; + bool isInputValid = false; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.initialValue.url); + focusNode = FocusNode()..addListener(_onOutFocus); + + setState(() { + auxCheckStatus = widget.initialValue.status; + }); + } + + @override + void dispose() { + focusNode.removeListener(_onOutFocus); + focusNode.dispose(); + controller.dispose(); + super.dispose(); + } + + void _onOutFocus() { + if (!focusNode.hasFocus && isInputValid) { + validateAuxilaryServerUrl(); + } + } + + Future validateAuxilaryServerUrl() async { + final url = controller.text; + setState(() => auxCheckStatus = AuxCheckStatus.loading); + + final isValid = + await ref.read(authProvider.notifier).validateAuxilaryServerUrl(url); + + setState(() { + if (mounted) { + auxCheckStatus = isValid ? AuxCheckStatus.valid : AuxCheckStatus.error; + } + }); + + widget.onValidated(url, widget.index, auxCheckStatus); + } + + String? validateUrl(String? url) { + try { + if (url == null || url.isEmpty || !Uri.parse(url).isAbsolute) { + isInputValid = false; + return 'validate_endpoint_error'.tr(); + } + } catch (_) { + isInputValid = false; + return 'validate_endpoint_error'.tr(); + } + + isInputValid = true; + return null; + } + + @override + Widget build(BuildContext context) { + return Dismissible( + key: ValueKey(widget.index.toString()), + direction: DismissDirection.endToStart, + onDismissed: (_) => widget.onDismissed(widget.index), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + trailing: ReorderableDragStartListener( + enabled: widget.enabled, + index: widget.index, + child: const Icon(Icons.drag_handle_rounded), + ), + leading: NetworkStatusIcon( + key: ValueKey('status_$auxCheckStatus'), + status: auxCheckStatus, + enabled: widget.enabled, + ), + subtitle: TextFormField( + enabled: widget.enabled, + onTapOutside: (_) => focusNode.unfocus(), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: validateUrl, + keyboardType: TextInputType.url, + style: const TextStyle( + fontFamily: 'Inconsolata', + fontWeight: FontWeight.w600, + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'http(s)://immich.domain.com', + contentPadding: const EdgeInsets.all(16), + filled: true, + fillColor: context.colorScheme.surfaceContainer, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red[300]!), + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: + context.isDarkTheme ? Colors.grey[900]! : Colors.grey[300]!, + ), + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + ), + controller: controller, + focusNode: focusNode, + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart new file mode 100644 index 0000000000..13c109fa0e --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart' as db_store; +import 'package:immich_mobile/widgets/settings/networking_settings/endpoint_input.dart'; + +class ExternalNetworkPreference extends HookConsumerWidget { + const ExternalNetworkPreference({super.key, required this.enabled}); + + final bool enabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entries = + useState([AuxilaryEndpoint(url: '', status: AuxCheckStatus.unknown)]); + final canSave = useState(false); + + saveEndpointList() { + canSave.value = + entries.value.every((e) => e.status == AuxCheckStatus.valid); + + final endpointList = entries.value + .where((url) => url.status == AuxCheckStatus.valid) + .toList(); + + final jsonString = jsonEncode(endpointList); + + db_store.Store.put( + db_store.StoreKey.externalEndpointList, + jsonString, + ); + } + + updateValidationStatus(String url, int index, AuxCheckStatus status) { + entries.value[index] = + entries.value[index].copyWith(url: url, status: status); + + saveEndpointList(); + } + + handleReorder(int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + + final entry = entries.value.removeAt(oldIndex); + entries.value.insert(newIndex, entry); + entries.value = [...entries.value]; + + saveEndpointList(); + } + + handleDismiss(int index) { + entries.value = [...entries.value..removeAt(index)]; + + saveEndpointList(); + } + + Widget proxyDecorator( + Widget child, + int index, + Animation animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return Material( + color: context.colorScheme.surfaceContainerHighest, + shadowColor: context.colorScheme.primary.withOpacity(0.2), + child: child, + ); + }, + child: child, + ); + } + + useEffect( + () { + final jsonString = + db_store.Store.tryGet(db_store.StoreKey.externalEndpointList); + + if (jsonString == null) { + return null; + } + + final List jsonList = jsonDecode(jsonString); + entries.value = + jsonList.map((e) => AuxilaryEndpoint.fromJson(e)).toList(); + return null; + }, + const [], + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.colorScheme.surfaceContainerLow, + border: Border.all( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Stack( + children: [ + Positioned( + bottom: -36, + right: -36, + child: Icon( + Icons.dns_rounded, + size: 120, + color: context.primaryColor.withOpacity(0.05), + ), + ), + ListView( + padding: const EdgeInsets.symmetric(vertical: 16.0), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 24, + ), + child: Text( + "external_network_sheet_info".tr(), + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Divider(color: context.colorScheme.surfaceContainerHighest), + Form( + key: GlobalKey(), + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + proxyDecorator: proxyDecorator, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: entries.value.length, + onReorder: handleReorder, + itemBuilder: (context, index) { + return EndpointInput( + key: Key(index.toString()), + index: index, + initialValue: entries.value[index], + onValidated: updateValidationStatus, + onDismissed: handleDismiss, + enabled: enabled, + ); + }, + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: SizedBox( + height: 48, + child: OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text('add_endpoint'.tr().toUpperCase()), + onPressed: enabled + ? () { + entries.value = [ + ...entries.value, + AuxilaryEndpoint( + url: '', + status: AuxCheckStatus.unknown, + ), + ]; + } + : null, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart new file mode 100644 index 0000000000..0258cc3847 --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/local_network_preference.dart @@ -0,0 +1,256 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/network.provider.dart'; + +class LocalNetworkPreference extends HookConsumerWidget { + const LocalNetworkPreference({ + super.key, + required this.enabled, + }); + + final bool enabled; + + Future _showEditDialog( + BuildContext context, + String title, + String hintText, + String initialValue, + ) { + final controller = TextEditingController(text: initialValue); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: hintText, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'cancel'.tr().toUpperCase(), + style: const TextStyle(color: Colors.red), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('save'.tr().toUpperCase()), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wifiNameText = useState(""); + final localEndpointText = useState(""); + + useEffect( + () { + final wifiName = ref.read(authProvider.notifier).getSavedWifiName(); + final localEndpoint = + ref.read(authProvider.notifier).getSavedLocalEndpoint(); + + if (wifiName != null) { + wifiNameText.value = wifiName; + } + + if (localEndpoint != null) { + localEndpointText.value = localEndpoint; + } + + return null; + }, + [], + ); + + saveWifiName(String wifiName) { + wifiNameText.value = wifiName; + return ref.read(authProvider.notifier).saveWifiName(wifiName); + } + + saveLocalEndpoint(String url) { + localEndpointText.value = url; + return ref.read(authProvider.notifier).saveLocalEndpoint(url); + } + + handleEditWifiName() async { + final wifiName = await _showEditDialog( + context, + "wifi_name".tr(), + "your_wifi_name".tr(), + wifiNameText.value, + ); + + if (wifiName != null) { + await saveWifiName(wifiName); + } + } + + handleEditServerEndpoint() async { + final localEndpoint = await _showEditDialog( + context, + "server_endpoint".tr(), + "http://local-ip:2283/api", + localEndpointText.value, + ); + + if (localEndpoint != null) { + await saveLocalEndpoint(localEndpoint); + } + } + + autofillCurrentNetwork() async { + final wifiName = await ref.read(networkProvider.notifier).getWifiName(); + + if (wifiName == null) { + context.showSnackBar( + SnackBar( + content: Text( + "get_wifiname_error".tr(), + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSecondary, + ), + ), + backgroundColor: context.colorScheme.secondary, + ), + ); + } else { + saveWifiName(wifiName); + } + + final serverEndpoint = + ref.read(authProvider.notifier).getServerEndpoint(); + + if (serverEndpoint != null) { + saveLocalEndpoint(serverEndpoint); + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Stack( + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.colorScheme.surfaceContainerLow, + border: Border.all( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Stack( + children: [ + Positioned( + bottom: -36, + right: -36, + child: Icon( + Icons.home_outlined, + size: 120, + color: context.primaryColor.withOpacity(0.05), + ), + ), + ListView( + padding: const EdgeInsets.symmetric(vertical: 16.0), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 24, + ), + child: Text( + "local_network_sheet_info".tr(), + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Divider( + color: context.colorScheme.surfaceContainerHighest, + ), + ListTile( + enabled: enabled, + contentPadding: const EdgeInsets.only(left: 24, right: 8), + leading: const Icon(Icons.wifi_rounded), + title: Text("wifi_name".tr()), + subtitle: wifiNameText.value.isEmpty + ? Text("enter_wifi_name".tr()) + : Text( + wifiNameText.value, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: enabled + ? context.primaryColor + : context.colorScheme.onSurface + .withAlpha(100), + fontFamily: 'Inconsolata', + ), + ), + trailing: IconButton( + onPressed: enabled ? handleEditWifiName : null, + icon: const Icon(Icons.edit_rounded), + ), + ), + ListTile( + enabled: enabled, + contentPadding: const EdgeInsets.only(left: 24, right: 8), + leading: const Icon(Icons.lan_rounded), + title: Text("server_endpoint".tr()), + subtitle: localEndpointText.value.isEmpty + ? const Text("http://local-ip:2283/api") + : Text( + localEndpointText.value, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: enabled + ? context.primaryColor + : context.colorScheme.onSurface + .withAlpha(100), + fontFamily: 'Inconsolata', + ), + ), + trailing: IconButton( + onPressed: enabled ? handleEditServerEndpoint : null, + icon: const Icon(Icons.edit_rounded), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), + child: SizedBox( + height: 48, + child: OutlinedButton.icon( + icon: const Icon(Icons.wifi_find_rounded), + label: + Text('use_current_connection'.tr().toUpperCase()), + onPressed: enabled ? autofillCurrentNetwork : null, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart new file mode 100644 index 0000000000..59d05fd4cf --- /dev/null +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -0,0 +1,266 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; +import 'package:immich_mobile/providers/network.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; +import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +import 'package:immich_mobile/entities/store.entity.dart' as db_store; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class NetworkingSettings extends HookConsumerWidget { + const NetworkingSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentEndpoint = + db_store.Store.get(db_store.StoreKey.serverEndpoint); + final featureEnabled = + useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); + + Future checkWifiReadPermission() async { + final [hasLocationInUse, hasLocationAlways] = await Future.wait([ + ref.read(networkProvider.notifier).getWifiReadPermission(), + ref.read(networkProvider.notifier).getWifiReadBackgroundPermission(), + ]); + + bool? isGrantLocationAlwaysPermission; + + if (!hasLocationInUse) { + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("location_permission".tr()), + content: Text("location_permission_content".tr()), + actions: [ + TextButton( + onPressed: () async { + final isGrant = await ref + .read(networkProvider.notifier) + .requestWifiReadPermission(); + + Navigator.pop(context, isGrant); + }, + child: Text("grant_permission".tr()), + ), + ], + ); + }, + ); + } + + if (!hasLocationAlways) { + isGrantLocationAlwaysPermission = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("background_location_permission".tr()), + content: Text("background_location_permission_content".tr()), + actions: [ + TextButton( + onPressed: () async { + final isGrant = await ref + .read(networkProvider.notifier) + .requestWifiReadBackgroundPermission(); + + Navigator.pop(context, isGrant); + }, + child: Text("grant_permission".tr()), + ), + ], + ); + }, + ); + } + + if (isGrantLocationAlwaysPermission != null && + !isGrantLocationAlwaysPermission) { + await ref.read(networkProvider.notifier).openSettings(); + } + } + + useEffect( + () { + if (featureEnabled.value == true) { + checkWifiReadPermission(); + } + return null; + }, + [featureEnabled.value], + ); + + return ListView( + padding: const EdgeInsets.only(bottom: 96), + physics: const ClampingScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), + child: NetworkPreferenceTitle( + title: "current_server_address".tr().toUpperCase(), + icon: currentEndpoint.startsWith('https') + ? Icons.https_outlined + : Icons.http_outlined, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(16)), + side: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: ListTile( + leading: + const Icon(Icons.check_circle_rounded, color: Colors.green), + title: Text( + currentEndpoint, + style: TextStyle( + fontSize: 16, + fontFamily: 'Inconsolata', + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Divider( + color: context.colorScheme.surfaceContainerHighest, + ), + ), + SettingsSwitchListTile( + enabled: true, + valueNotifier: featureEnabled, + title: "automatic_endpoint_switching_title".tr(), + subtitle: "automatic_endpoint_switching_subtitle".tr(), + ), + Padding( + padding: const EdgeInsets.only(top: 8, left: 16, bottom: 16), + child: NetworkPreferenceTitle( + title: "local_network".tr().toUpperCase(), + icon: Icons.home_outlined, + ), + ), + LocalNetworkPreference( + enabled: featureEnabled.value, + ), + Padding( + padding: const EdgeInsets.only(top: 32, left: 16, bottom: 16), + child: NetworkPreferenceTitle( + title: "external_network".tr().toUpperCase(), + icon: Icons.dns_outlined, + ), + ), + ExternalNetworkPreference( + enabled: featureEnabled.value, + ), + ], + ); + } +} + +class NetworkPreferenceTitle extends StatelessWidget { + const NetworkPreferenceTitle({ + super.key, + required this.icon, + required this.title, + }); + + final IconData icon; + final String title; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon( + icon, + color: context.colorScheme.onSurface.withAlpha(150), + ), + const SizedBox(width: 8), + Text( + title, + style: context.textTheme.displaySmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} + +class NetworkStatusIcon extends StatelessWidget { + const NetworkStatusIcon({ + super.key, + required this.status, + this.enabled = true, + }) : super(); + + final AuxCheckStatus status; + final bool enabled; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _buildIcon(context), + ); + } + + Widget _buildIcon(BuildContext context) { + switch (status) { + case AuxCheckStatus.loading: + return Padding( + padding: const EdgeInsets.only(left: 4.0), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: context.primaryColor, + strokeWidth: 2, + key: const ValueKey('loading'), + ), + ), + ); + case AuxCheckStatus.valid: + return enabled + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + key: ValueKey('success'), + ) + : Icon( + Icons.check_circle_rounded, + color: context.colorScheme.onSurface.withAlpha(100), + key: const ValueKey('success'), + ); + case AuxCheckStatus.error: + return enabled + ? const Icon( + Icons.error_rounded, + color: Colors.red, + key: ValueKey('error'), + ) + : const Icon( + Icons.error_rounded, + color: Colors.grey, + key: ValueKey('error'), + ); + default: + return const Icon(Icons.circle_outlined, key: ValueKey('unknown')); + } + } +} diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9203dcdf82..34eb217828 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1017,6 +1017,22 @@ packages: url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" + network_info_plus: + dependency: "direct main" + description: + name: network_info_plus + sha256: bf9e39e523e9951d741868dc33ac386b0bc24301e9b7c8a7d60dbc34879150a8 + url: "https://pub.dev" + source: hosted + version: "6.1.1" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: b7f35f4a7baef511159e524499f3c15464a49faa5ec10e92ee0bce265e664906 + url: "https://pub.dev" + source: hosted + version: "2.0.1" nm: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a037f9b947..e8bee37653 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 + network_info_plus: ^6.1.1 native_video_player: git: url: https://github.com/immich-app/native_video_player diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index de49a98cc4..507b4f281b 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; +import 'package:immich_mobile/services/network.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,3 +15,5 @@ class MockSyncService extends Mock implements SyncService {} class MockHashService extends Mock implements HashService {} class MockEntityService extends Mock implements EntityService {} + +class MockNetworkService extends Mock implements NetworkService {} diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index b864babb14..edbf6495e3 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -1,8 +1,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; import '../repository.mocks.dart'; import '../service.mocks.dart'; import '../test_utils.dart'; @@ -12,12 +14,22 @@ void main() { late MockAuthApiRepository authApiRepository; late MockAuthRepository authRepository; late MockApiService apiService; + late MockNetworkService networkService; setUp(() async { authApiRepository = MockAuthApiRepository(); authRepository = MockAuthRepository(); apiService = MockApiService(); - sut = AuthService(authApiRepository, authRepository, apiService); + networkService = MockNetworkService(); + + sut = AuthService( + authApiRepository, + authRepository, + apiService, + networkService, + ); + + registerFallbackValue(Uri()); }); group('validateServerUrl', () { @@ -115,4 +127,182 @@ void main() { verify(() => authRepository.clearLocalData()).called(1); }); }); + + group('setOpenApiServiceEndpoint', () { + setUp(() { + when(() => networkService.getWifiName()) + .thenAnswer((_) async => 'TestWifi'); + }); + + test('Should return null if auto endpoint switching is disabled', () async { + when(() => authRepository.getEndpointSwitchingFeature()) + .thenReturn((false)); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verifyNever(() => networkService.getWifiName()); + }); + + test('Should set local connection if wifi name matches', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi'); + when(() => authRepository.getLocalEndpoint()) + .thenReturn('http://local.endpoint'); + when(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .thenAnswer((_) async => 'http://local.endpoint'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'http://local.endpoint'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getLocalEndpoint()).called(1); + verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .called(1); + }); + + test('Should set external endpoint if wifi name not matching', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + ]); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenAnswer((_) async => 'https://external.endpoint/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).called(1); + }); + + test('Should set second external endpoint if the first throw any error', + () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + AuxilaryEndpoint( + url: 'https://external.endpoint2', + status: AuxCheckStatus.valid, + ), + ]); + + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(Exception('Invalid endpoint')); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).thenAnswer((_) async => 'https://external.endpoint2/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint2/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).called(1); + }); + + test('Should set second external endpoint if the first throw ApiException', + () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + AuxilaryEndpoint( + url: 'https://external.endpoint2', + status: AuxCheckStatus.valid, + ), + ]); + + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(ApiException(503, 'Invalid endpoint')); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).thenAnswer((_) async => 'https://external.endpoint2/api'); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, 'https://external.endpoint2/api'); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint2'), + ).called(1); + }); + + test('Should handle error when setting local connection', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()).thenReturn('TestWifi'); + when(() => authRepository.getLocalEndpoint()) + .thenReturn('http://local.endpoint'); + when(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .thenThrow(Exception('Local endpoint error')); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getLocalEndpoint()).called(1); + verify(() => apiService.resolveAndSetEndpoint('http://local.endpoint')) + .called(1); + }); + + test('Should handle error when setting external connection', () async { + when(() => authRepository.getEndpointSwitchingFeature()).thenReturn(true); + when(() => authRepository.getPreferredWifiName()) + .thenReturn('DifferentWifi'); + when(() => authRepository.getExternalEndpointList()).thenReturn([ + AuxilaryEndpoint( + url: 'https://external.endpoint', + status: AuxCheckStatus.valid, + ), + ]); + when( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).thenThrow(Exception('External endpoint error')); + + final result = await sut.setOpenApiServiceEndpoint(); + + expect(result, isNull); + verify(() => authRepository.getEndpointSwitchingFeature()).called(1); + verify(() => networkService.getWifiName()).called(1); + verify(() => authRepository.getPreferredWifiName()).called(1); + verify(() => authRepository.getExternalEndpointList()).called(1); + verify( + () => apiService.resolveAndSetEndpoint('https://external.endpoint'), + ).called(1); + }); + }); } From bb0242ae0a468cc408196b922fbcbbb8b04dfe6d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 5 Dec 2024 17:11:02 +0100 Subject: [PATCH 513/599] chore(web): update translations (#14255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alisher Nabiev Co-authored-by: Armand Maree Co-authored-by: Bezruchenko Simon Co-authored-by: Daniel Co-authored-by: Dean Cvjetanović Co-authored-by: Enoé Mugnaschi Co-authored-by: Enrico Zangrando Co-authored-by: Eugenio Marotta Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: Jiri Grönroos Co-authored-by: Jonathan Co-authored-by: João Pedro Grugel Co-authored-by: KecskeTech Co-authored-by: Koen <62koen@users.noreply.hosted.weblate.org> Co-authored-by: Leo Bottaro Co-authored-by: LeonardoCasarotto Co-authored-by: Linerly Co-authored-by: Manar Aldroubi Co-authored-by: Marco Lampis Co-authored-by: Matjaž T Co-authored-by: Max Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Mickaël Descamps Co-authored-by: Miki Mrvos Co-authored-by: OskarSidor Co-authored-by: Paweł Co-authored-by: Petri Hämäläinen Co-authored-by: Ramazan S Co-authored-by: Rasulmmdv Co-authored-by: Rookie Nguyễn Co-authored-by: Stan P Co-authored-by: Stijn Co-authored-by: Stsiapan Ranchynski Co-authored-by: Suryo Wibowo Co-authored-by: Sylvain Pichon Co-authored-by: Sylvain Pichon Co-authored-by: Theofilos Nikolaou Co-authored-by: Vegard Fladby Co-authored-by: Viliam Co-authored-by: Vladislav Tkalin Co-authored-by: Xo Co-authored-by: bill85101 Co-authored-by: chamdim Co-authored-by: gallegonovato Co-authored-by: mitakskia Co-authored-by: pyccl Co-authored-by: stelle Co-authored-by: therry47 Co-authored-by: tomechio Co-authored-by: waclaw66 Co-authored-by: Ömer Efe ÇELİK Co-authored-by: Вячеслав Лукьяненко Co-authored-by: Мĕтри Сантăр ывалĕ Упа-Миччи --- i18n/af.json | 58 ++++++++++++++++- i18n/ar.json | 16 ++++- i18n/az.json | 11 +++- i18n/be.json | 59 +++++++++++++++-- i18n/bg.json | 4 +- i18n/ca.json | 5 ++ i18n/cs.json | 23 ++++++- i18n/cv.json | 5 +- i18n/de.json | 32 ++++++--- i18n/el.json | 57 ++++++++++------ i18n/es.json | 23 ++++++- i18n/et.json | 23 ++++++- i18n/fi.json | 12 ++++ i18n/fr.json | 140 ++++++++++++++++++++++++---------------- i18n/he.json | 19 +++++- i18n/hr.json | 47 ++++++++++++-- i18n/hu.json | 12 ++++ i18n/id.json | 12 ++++ i18n/it.json | 20 ++++-- i18n/ms.json | 26 +++++++- i18n/nb_NO.json | 11 +++- i18n/nl.json | 20 ++++-- i18n/pl.json | 7 ++ i18n/pt.json | 12 ++++ i18n/pt_BR.json | 36 +++++++++-- i18n/ru.json | 23 ++++--- i18n/sk.json | 60 +++++++++++++---- i18n/sl.json | 20 ++++-- i18n/sr_Cyrl.json | 13 ++++ i18n/sr_Latn.json | 7 ++ i18n/tr.json | 12 ++++ i18n/uk.json | 20 ++++-- i18n/vi.json | 11 +++- i18n/zh_Hant.json | 12 ++++ i18n/zh_SIMPLIFIED.json | 7 ++ 35 files changed, 718 insertions(+), 157 deletions(-) diff --git a/i18n/af.json b/i18n/af.json index 0967ef424b..ede1a745eb 100644 --- a/i18n/af.json +++ b/i18n/af.json @@ -1 +1,57 @@ -{} +{ + "about": "Verfris", + "account": "Rekening", + "account_settings": "Rekeninginstellings", + "acknowledge": "Erken", + "action": "Aksie", + "actions": "Aksies", + "active": "Aktief", + "activity": "Aktiwiteite", + "activity_changed": "Aktiwiteit is {enabled, select, true {aangeskakel} other {afgeskakel}}", + "add": "Voegby", + "add_a_description": "Voeg 'n beskrywing by", + "add_a_location": "Voeg 'n ligging by", + "add_a_name": "Voeg 'n naam by", + "add_a_title": "Voeg 'n titel 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", + "add_path": "Voeg pad by", + "add_photos": "Voeg foto's by", + "add_to": "Voeg na...", + "add_to_album": "Voeg na album", + "add_to_shared_album": "Voeg na gedeelde album", + "added_to_archive": "By argief gevoeg", + "added_to_favorites": "By gunstelinge gevoeg", + "added_to_favorites_count": "Het {count, number} by gunstelinge gevoeg", + "admin": { + "add_exclusion_pattern_description": "Voeg uitsluitingspatrone by. Globbing met *, ** en ? word ondersteun. Om alle lêers in enige lêergids genaamd \"Raw\" te ignoreer, gebruik \"**/Raw/**\". Om alle lêers wat op \".tif\" eindig, te ignoreer, gebruik \"**/*.tif\". Om 'n absolute pad te ignoreer, gebruik \"/path/to/ignore/**\".", + "asset_offline_description": "Hierdie eksterne biblioteekbate word nie meer op skyf gevind nie en is na die asblik geskuif. As die lêer binne die biblioteek geskuif is, gaan jou tydlyn na vir die nuwe ooreenstemmende bate. Om hierdie bate te herstel, maak asseblief seker dat die lêerpad hieronder deur Immich verkry kan word en skandeer die biblioteek.", + "authentication_settings": "Verifikasie instellings", + "authentication_settings_description": "Bestuur wagwoord, OAuth en ander verifikasie instellings", + "authentication_settings_disable_all": "Is jy seker jy wil alle aanmeldmetodes deaktiveer? Aanmelding sal heeltemal gedeaktiveer word.", + "authentication_settings_reenable": "Om te heraktiveer, gebruik 'n Server Command.", + "background_task_job": "Agtergrondtake", + "backup_database": "Rugsteun databasis", + "backup_database_enable_description": "Aktiveer databasisrugsteun", + "backup_keep_last_amount": "Aantal vorige rugsteune om te hou", + "backup_settings": "Rugsteun instellings", + "backup_settings_description": "Bestuur databasis rugsteun instellings", + "check_all": "Kies Alles", + "cleared_jobs": "Poste gevee vir: {job}", + "config_set_by_file": "Config word tans deur 'n konfigurasielêer gestel", + "confirm_delete_library": "Is jy seker jy wil {library}-biblioteek uitvee?", + "confirm_delete_library_assets": "Is jy seker jy wil hierdie biblioteek uitvee? Dit sal {count, plural, one {# bevatte base} other {# bevatte bates}} uit Immich uitvee en kan nie ongedaan gemaak word nie. Lêers sal op skyf bly.", + "confirm_email_below": "Om te bevestig, tik \"{email}\" hieronder", + "confirm_reprocess_all_faces": "Is jy seker jy wil alle gesigte herverwerk? Dit sal ook genoemde mense skoonmaak.", + "confirm_user_password_reset": "Is jy seker jy wil {user} se wagwoord terugstel?", + "create_job": "Skep werk", + "cron_expression": "Cron uitdrukking", + "cron_expression_description": "Stel die skanderingsinterval in met die cron-formaat. Vir meer inligting verwys asseblief na bv. Crontab Guru", + "cron_expression_presets": "Cron uitdrukking voorafinstellings", + "disable_login": "Deaktiveer aanmelding", + "duplicate_detection_job_description": "Begin masjienleer op bates om soortgelyke beelde op te spoor. Maak staat op Smart Search" + } +} diff --git a/i18n/ar.json b/i18n/ar.json index 41aa700ebe..7e1805ca34 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -1,5 +1,5 @@ { - "about": "حول", + "about": "تحديث", "account": "الحساب", "account_settings": "إعدادات الحساب", "acknowledge": "أُدرك ذلك", @@ -222,6 +222,8 @@ "send_welcome_email": "إرسال بريد ترحيبي", "server_external_domain_settings": "إسم النطاق الخارجي", "server_external_domain_settings_description": "إسم النطاق لروابط المشاركة العامة، بما في ذلك http(s)://", + "server_public_users": "المستخدمون العامون", + "server_public_users_description": "يتم إدراج جميع المستخدمين (الاسم والبريد الإلكتروني) عند إضافة مستخدم إلى الألبومات المشتركة. عند تعطيل هذه الميزة، ستكون قائمة المستخدمين متاحة فقط لمستخدمي الإدارة.", "server_settings": "إعدادات الخادم", "server_settings_description": "إدارة إعدادات الخادم", "server_welcome_message": "الرسالة الترحيبية", @@ -465,6 +467,7 @@ "confirm": "تأكيد", "confirm_admin_password": "تأكيد كلمة مرور المسؤول", "confirm_delete_shared_link": "هل أنت متأكد أنك تريد حذف هذا الرابط المشترك؟", + "confirm_keep_this_delete_others": "سيتم حذف جميع الأصول الأخرى في المجموعة باستثناء هذا الأصل. هل أنت متأكد من أنك تريد المتابعة؟", "confirm_password": "تأكيد كلمة المرور", "contain": "محتواة", "context": "السياق", @@ -514,6 +517,7 @@ "delete_key": "حذف المفتاح", "delete_library": "حذف المكتبة", "delete_link": "حذف الرابط", + "delete_others": "حذف الأخرى", "delete_shared_link": "حذف الرابط المشترك", "delete_tag": "حذف العلامة", "delete_tag_confirmation_prompt": "هل أنت متأكد أنك تريد حذف العلامة {tagName}؟", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "فشل إنشاء رابط مشترك", "failed_to_edit_shared_link": "فشل تعديل الرابط المشترك", "failed_to_get_people": "فشل في الحصول على الناس", + "failed_to_keep_this_delete_others": "فشل في الاحتفاظ بهذا الأصل وحذف الأصول الأخرى", "failed_to_load_asset": "فشل تحميل المحتوى", "failed_to_load_assets": "فشل تحميل المحتويات", "failed_to_load_people": "فشل تحميل الأشخاص", @@ -787,6 +792,8 @@ "jobs": "الوظائف", "keep": "احتفظ", "keep_all": "احتفظ بالكل", + "keep_this_delete_others": "احتفظ بهذا، واحذف الآخرين", + "kept_this_deleted_others": "تم الاحتفاظ بهذا الأصل وحذف {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "اختصارات لوحة المفاتيح", "language": "اللغة", "language_setting_description": "اختر لغتك المفضلة", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "سيتم دمجهم معًا", "third_party_resources": "موارد الطرف الثالث", "time_based_memories": "ذكريات استنادًا للوقت", + "timeline": "الخط الزمني", "timezone": "المنطقة الزمنية", "to_archive": "أرشفة", "to_change_password": "تغيير كلمة المرور", @@ -1227,6 +1235,7 @@ "to_trash": "حذف", "toggle_settings": "الإعدادات", "toggle_theme": "تبديل المظهر الداكن", + "total": "الإجمالي", "total_usage": "الاستخدام الإجمالي", "trash": "المهملات", "trash_all": "نقل الكل إلى سلة المهملات", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "إدارة عملية الشراء الخاصة بك", "user_role_set": "قم بتعيين {user} كـ {role}", "user_usage_detail": "تفاصيل استخدام المستخدم", + "user_usage_stats": "إحصائيات استخدام الحساب", + "user_usage_stats_description": "عرض إحصائيات استخدام الحساب", "username": "اسم المستخدم", "users": "المستخدمين", "utilities": "أدوات", @@ -1283,7 +1294,7 @@ "variables": "المتغيرات", "version": "الإصدار", "version_announcement_closing": "صديقك، أليكس", - "version_announcement_message": "مرحباً يا صديقي، هنالك نسخة جديدة من التطبيق. خذ وقتك لزيارة ملاحظات الإصدار والتأكد من أن ملف docker-compose.yml وإعداد .env مُحدّثين لتجنب أي إعدادات خاطئة، خاصةً إذا كنت تستخدم WatchTower أو أي آلية تقوم بتحديث التطبيق تلقائياً.", + "version_announcement_message": "مرحبًا! يتوفر إصدار جديد من Immich. يُرجى تخصيص بعض الوقت لقراءة ملاحظات الإصدار للتأكد من تحديث إعداداتك لمنع أي أخطاء في التكوين، خاصة إذا كنت تستخدم WatchTower أو أي آلية تتولى تحديث مثيل Immich الخاص بك تلقائيًا.", "version_history": "تاريخ الإصدار", "version_history_item": "تم تثبيت {version} في {date}", "video": "فيديو", @@ -1297,6 +1308,7 @@ "view_all_users": "عرض كافة المستخدمين", "view_in_timeline": "عرض في الجدول الزمني", "view_links": "عرض الروابط", + "view_name": "عرض", "view_next_asset": "عرض المحتوى التالي", "view_previous_asset": "عرض المحتوى السابق", "view_stack": "عرض التكديس", diff --git a/i18n/az.json b/i18n/az.json index 5a5e8ac0c9..7848462414 100644 --- a/i18n/az.json +++ b/i18n/az.json @@ -1,8 +1,10 @@ { - "about": "Haqqında", + "about": "Yenilə", "account": "Hesab", "account_settings": "Hesab parametrləri", "acknowledge": "Təsdiq et", + "action": "Əməliyyat", + "actions": "Əməliyyatlar", "active": "Aktiv", "activity": "Fəaliyyət", "add": "Əlavə et", @@ -10,9 +12,12 @@ "add_a_location": "Məkan əlavə et", "add_a_name": "Ad əlavə et", "add_a_title": "Başlıq əlavə et", + "add_exclusion_pattern": "İstisna nümunəsi əlavə et", + "add_import_path": "Import yolunu əlavə et", "add_location": "Məkanı əlavə et", "add_more_users": "Daha çox istifadəçi əlavə et", "add_partner": "Partnyor əlavə et", + "add_path": "Yol əlavə et", "add_photos": "Şəkilləri əlavə et", "add_to": "... əlavə et", "add_to_album": "Albom əlavə et", @@ -26,7 +31,11 @@ "authentication_settings_disable_all": "Bütün giriş etmə metodlarını söndürmək istədiyinizdən əminsinizmi? Giriş etmə funksiyası tamamilə söndürüləcəkdir.", "authentication_settings_reenable": "Yenidən aktiv etmək üçün Server Əmri -ni istifadə edin.", "background_task_job": "Arxa plan tapşırıqları", + "backup_database_enable_description": "Verilənlər bazasının ehtiyat nüsxələrini aktiv et", + "backup_settings": "Ehtiyat Nüsxə Parametrləri", + "backup_settings_description": "Verilənlər bazasının ehtiyat nüsxə parametrlərini idarə et", "check_all": "Hamısını yoxla", + "config_set_by_file": "Konfiqurasiya hal-hazırda konfiqurasiya faylı ilə təyin olunub", "confirm_delete_library": "{library} kitabxanasını silmək istədiyinizdən əminmisiniz?", "confirm_email_below": "Təsdiqləmək üçün aşağıya {email} yazın", "confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?", diff --git a/i18n/be.json b/i18n/be.json index 8747b4ac8c..ff809e1aaf 100644 --- a/i18n/be.json +++ b/i18n/be.json @@ -1,7 +1,7 @@ { - "about": "Пра праграму", + "about": "Аднавіць", "account": "Уліковы запіс", - "account_settings": "Налады акаўнта", + "account_settings": "Налады ўліковага запісу", "acknowledge": "Пацвердзіць", "action": "Дзеянне", "actions": "Дзеянні", @@ -27,6 +27,57 @@ "added_to_favorites": "Дададзена ў абраныя", "added_to_favorites_count": "Дададзена {count, number} да абранага", "admin": { - "add_exclusion_pattern_description": "Дадайце шаблоны выключэнняў. Падтрымліваецца выкарыстанне сімвалаў * , ** і ?. Каб ігнараваць усе файлы ў любой дырэкторыі з назвай \"Raw\", выкарыстоўвайце \"**/Raw/**\". Каб ігнараваць усе файлы, якія заканчваюцца на \".tif\", выкарыстоўвайце \"**/.tif\". Каб ігнараваць абсолютны шлях, выкарыстоўвайце \"/path/to/ignore/**\"." - } + "add_exclusion_pattern_description": "Дадайце шаблоны выключэнняў. Падтрымліваецца выкарыстанне сімвалаў * , ** і ?. Каб ігнараваць усе файлы ў любой дырэкторыі з назвай \"Raw\", выкарыстоўвайце \"**/Raw/**\". Каб ігнараваць усе файлы, якія заканчваюцца на \".tif\", выкарыстоўвайце \"**/.tif\". Каб ігнараваць абсолютны шлях, выкарыстоўвайце \"/path/to/ignore/**\".", + "authentication_settings": "Налады праверкі сапраўднасці", + "authentication_settings_description": "Кіраванне паролямі, OAuth, і іншыя налады праверкі сапраўднасці", + "authentication_settings_disable_all": "Вы ўпэўнены, што жадаеце адключыць усе спосабы логіну? Логін будзе цалкам адключаны.", + "authentication_settings_reenable": "Каб зноў уключыць, выкарыстайце Каманду сервера.", + "background_task_job": "Фонавыя заданні", + "backup_database": "Рэзервовая копія базы даных", + "backup_database_enable_description": "Уключыць рэзерваванне базы даных", + "backup_settings": "Налады рэзервовага капіявання", + "check_all": "Праверыць усе", + "confirm_delete_library": "Вы ўпэўнены што жадаеце выдаліць {library} бібліятэку?", + "confirm_email_below": "Каб пацвердзіць, увядзіце \"{email}\" ніжэй", + "confirm_user_password_reset": "Вы ўпэўнены ў тым, што жадаеце скінуць пароль {user}?", + "disable_login": "Адключыць уваход", + "force_delete_user_warning": "ПАПЯРЭДЖАННЕ: Гэта дзеянне неадкладна выдаліць карыстальніка і ўсе аб'екты. Гэта дзеянне не можа быць адроблена і файлы немагчыма будзе аднавіць.", + "image_format": "Фармат", + "image_preview_title": "Налады папярэдняга прагляду", + "image_quality": "Якасць", + "image_resolution": "Раздзяляльнасць", + "image_settings": "Налады відарыса", + "image_settings_description": "Кіруйце якасцю і раздзяляльнасцю сгенерыраваных відарысаў" + }, + "timeline": "Хроніка", + "total": "Усяго", + "user": "Карыстальнік", + "user_id": "ID карыстальніка", + "user_usage_stats": "Статыстыка карыстання ўліковага запісу", + "user_usage_stats_description": "Прагледзець статыстыку карыстання ўліковага запісу", + "username": "Імя карыстальніка", + "users": "Карыстальнікі", + "utilities": "Утыліты", + "validate": "Праверыць", + "variables": "Пераменныя", + "version": "Версія", + "video": "Відэа", + "videos": "Відэа", + "view": "Прагляд", + "view_album": "Праглядзець альбом", + "view_all": "Праглядзець усё", + "view_all_users": "Праглядзець усех карыстальнікаў", + "view_in_timeline": "Паглядзець на хроніцы", + "view_links": "Праглядзець спасылкі", + "view_name": "Прагледзець", + "waiting": "Чакаюць", + "warning": "Папярэджанне", + "week": "Тыдзень", + "welcome": "Вітаем", + "welcome_to_immich": "Вітаем у Immich", + "year": "Год", + "years_ago": "{years, plural, one {# год} other {# гадоў}} таму", + "yes": "Так", + "you_dont_have_any_shared_links": "У вас няма абагуленых спасылак", + "zoom_image": "Павелічэнне відарыса" } diff --git a/i18n/bg.json b/i18n/bg.json index d24349c39a..83cfab9c67 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -1,5 +1,5 @@ { - "about": "За Immich", + "about": "Обновяване", "account": "Акаунт", "account_settings": "Настройки на профила", "acknowledge": "Потвърждавам", @@ -1059,6 +1059,8 @@ "user_purchase_settings_description": "Управлявай покупката си", "user_role_set": "Задай {user} като {role}", "user_usage_detail": "Подробности за използването на потребителя", + "user_usage_stats": "Статистика за използването на акаунта", + "user_usage_stats_description": "Преглед на статистиката за използването на акаунта", "username": "Потребителско име", "users": "Потребители", "utilities": "Инструменти", diff --git a/i18n/ca.json b/i18n/ca.json index 577e1eeed3..c369f722c1 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -465,6 +465,7 @@ "confirm": "Confirmar", "confirm_admin_password": "Confirmeu la contrasenya d'administrador", "confirm_delete_shared_link": "Esteu segurs que voleu eliminar aquest enllaç compartit?", + "confirm_keep_this_delete_others": "Excepte aquest element, tots els altres de la pila se suprimiran. Esteu segur que voleu continuar?", "confirm_password": "Confirmació de contrasenya", "contain": "Contingut", "context": "Context", @@ -514,6 +515,7 @@ "delete_key": "Suprimeix la clau", "delete_library": "Suprimeix la Llibreria", "delete_link": "Esborra l'enllaç", + "delete_others": "Suprimeix altres", "delete_shared_link": "Odstranit sdílený odkaz", "delete_tag": "Eliminar etiqueta", "delete_tag_confirmation_prompt": "Estàs segur que vols eliminar l'etiqueta {tagName}?", @@ -604,6 +606,7 @@ "failed_to_create_shared_link": "No s'ha pogut crear l'enllaç compartit", "failed_to_edit_shared_link": "No s'ha pogut editar l'enllaç compartit", "failed_to_get_people": "No s'han pogut aconseguir persones", + "failed_to_keep_this_delete_others": "No s'ha pogut conservar aquest element i suprimir els altres", "failed_to_load_asset": "No s'ha pogut carregar l'element", "failed_to_load_assets": "No s'han pogut carregar els elements", "failed_to_load_people": "No s'han pogut carregar les persones", @@ -787,6 +790,8 @@ "jobs": "Tasques", "keep": "Mantenir", "keep_all": "Mantenir-ho tot", + "keep_this_delete_others": "Conserveu-ho, suprimiu-ne els altres", + "kept_this_deleted_others": "S'ha conservat aquest element i s'han suprimit {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Dreceres de teclat", "language": "Idioma", "language_setting_description": "Seleccioneu el vostre idioma", diff --git a/i18n/cs.json b/i18n/cs.json index fa49cf6454..e6997e2287 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -23,6 +23,7 @@ "add_to": "Přidat do...", "add_to_album": "Přidat do alba", "add_to_shared_album": "Přidat do sdíleného alba", + "add_url": "Přidat URL", "added_to_archive": "Přidáno do archivu", "added_to_favorites": "Přidáno do oblíbených", "added_to_favorites_count": "Přidáno {count, number} do oblíbených", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Sémantické vyhledávání obrázků pomocí CLIP embeddings", "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 pro strojové učení", + "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.", "manage_concurrency": "Správa souběžnosti", "manage_log_settings": "Správa nastavení protokolu", "map_dark_style": "Tmavý motiv", @@ -222,6 +223,8 @@ "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", + "server_public_users": "Veřejní uživatelé", + "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_welcome_message": "Uvítací zpráva", @@ -247,6 +250,16 @@ "storage_template_user_label": "{label} je štítek úložiště uživatele", "system_settings": "Systémová nastavení", "tag_cleanup_job": "Promazání značek", + "template_email_available_tags": "V šabloně můžete použít následující proměnné: {tags}", + "template_email_if_empty": "Pokud je šablona prázdná, použije se výchozí e-mail.", + "template_email_invite_album": "Šablona pozvánky do alba", + "template_email_preview": "Náhled", + "template_email_settings": "Šablony e-mailů", + "template_email_settings_description": "Správa vlastních šablon e-mailových oznámení", + "template_email_update_album": "Aktualizace šablony alba", + "template_email_welcome": "Šablona uvítacího e-mailu", + "template_settings": "Šablony oznámení", + "template_settings_description": "Správa vlastních šablon oznámení.", "theme_custom_css_settings": "Vlastní CSS", "theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.", "theme_settings": "Motivy", @@ -721,6 +734,7 @@ "external": "Externí", "external_libraries": "Externí knihovny", "face_unassigned": "Nepřiřazena", + "failed_to_load_assets": "Nepodařilo se načíst položky", "favorite": "Oblíbit", "favorite_or_unfavorite_photo": "Oblíbit nebo zrušit oblíbení fotky", "favorites": "Oblíbené", @@ -1020,6 +1034,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {Přeřazena # položka} few {Přeřazeny # položky} other {Přeřazeno # položek}} na novou osobu", "reassing_hint": "Přiřazení vybraných položek existující osobě", "recent": "Nedávné", + "recent-albums": "Nedávná alba", "recent_searches": "Nedávná vyhledávání", "refresh": "Obnovit", "refresh_encoded_videos": "Obnovit kódovaná videa", @@ -1041,6 +1056,7 @@ "remove_from_album": "Odstranit z alba", "remove_from_favorites": "Odstranit z oblíbených", "remove_from_shared_link": "Odstranit ze sdíleného odkazu", + "remove_url": "Odstranit URL", "remove_user": "Odebrat uživatele", "removed_api_key": "Odstraněn API klíč: {name}", "removed_from_archive": "Odstraněno z archivu", @@ -1223,6 +1239,7 @@ "they_will_be_merged_together": "Budou sloučeny dohromady", "third_party_resources": "Zdroje třetích stran", "time_based_memories": "Časové vzpomínky", + "timeline": "Časová osa", "timezone": "Časové pásmo", "to_archive": "Archivovat", "to_change_password": "Změnit heslo", @@ -1232,6 +1249,7 @@ "to_trash": "Vyhodit", "toggle_settings": "Přepnout nastavení", "toggle_theme": "Přepnout tmavý motiv", + "total": "Celkem", "total_usage": "Celkové využití", "trash": "Koš", "trash_all": "Vyhodit vše", @@ -1281,6 +1299,8 @@ "user_purchase_settings_description": "Správa vašeho nákupu", "user_role_set": "Uživatel {user} nastaven jako {role}", "user_usage_detail": "Podrobnosti využití uživatelů", + "user_usage_stats": "Statistiky používání účtu", + "user_usage_stats_description": "Zobrazit statistiky používání účtu", "username": "Uživateleské jméno", "users": "Uživatelé", "utilities": "Nástroje", @@ -1302,6 +1322,7 @@ "view_all_users": "Zobrazit všechny uživatele", "view_in_timeline": "Zobrazit na časové ose", "view_links": "Zobrazit odkazy", + "view_name": "Zobrazit", "view_next_asset": "Zobrazit další položku", "view_previous_asset": "Zobrazit předchozí položku", "view_stack": "Zobrazit seskupení", diff --git a/i18n/cv.json b/i18n/cv.json index 8f0581053e..61dcb12b8d 100644 --- a/i18n/cv.json +++ b/i18n/cv.json @@ -23,6 +23,7 @@ "add_to": "Мӗн те пулин хуш...", "add_to_album": "Альбома хуш", "add_to_shared_album": "Пӗрлехи альбома хуш", + "add_url": "URL хушӑр", "added_to_archive": "Архива хушнӑ", "added_to_favorites": "Суйласа илнине хушнӑ", "added_to_favorites_count": "Суйласа илнине {count, number} хушнӑ", @@ -45,5 +46,7 @@ "image_preview_title": "Малтанлӑха пӑхмалли ӗнерлевсем", "image_quality": "Пахалӑх", "image_resolution": "Виҫе" - } + }, + "user_usage_stats": "Шута ҫырни усӑ курмалли статистика", + "user_usage_stats_description": "Шута ҫырни усӑ курмалли статистикӑна пӑхасси" } diff --git a/i18n/de.json b/i18n/de.json index 849aa0337c..e1eaf57304 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -222,6 +222,8 @@ "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", + "server_public_users": "Öffentliche Benutzer", + "server_public_users_description": "Beim hinzufügen eines benutzers zu freigegebenen alben werden alle benutzer (name und e-mail) aufgelistet. Wenn diese option deaktiviert ist, steht die benutzerliste nur administratorbenutzern zur verfügung.", "server_settings": "Servereinstellungen", "server_settings_description": "Servereinstellungen verwalten", "server_welcome_message": "Willkommensnachricht", @@ -406,7 +408,7 @@ "assets_added_to_name_count": "{count, plural, one {# Element} other {# Elemente}} zu {hasName, select, true {{name}} other {neuem Album}} hinzugefügt", "assets_count": "{count, plural, one {# Datei} other {# Dateien}}", "assets_moved_to_trash_count": "{count, plural, one {# Datei} other {# Dateien}} in den Papierkorb verschoben", - "assets_permanently_deleted_count": "{count, plural, one {# Datei} other {# Dateien}} dauerhaft gelöscht", + "assets_permanently_deleted_count": "{count, plural, one {# Datei} other {# Dateien}} endgültig gelöscht", "assets_removed_count": "{count, plural, one {# Datei} other {# Dateien}} entfernt", "assets_restore_confirmation": "Bist du sicher, dass du alle Dateien aus dem Papierkorb wiederherstellen willst? Diese Aktion kann nicht rückgängig gemacht werden! Beachte, dass Offline-Dateien auf diese Weise nicht wiederhergestellt werden können.", "assets_restored_count": "{count, plural, one {# Datei} other {# Dateien}} wiederhergestellt", @@ -422,7 +424,7 @@ "bugs_and_feature_requests": "Fehler & Verbesserungsvorschläge", "build": "Build", "build_image": "Build Abbild", - "bulk_delete_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} löschen möchtest? Dabei wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!", + "bulk_delete_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} löschen möchtest? Dabei wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!", "bulk_keep_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien}} behalten möchtest? Dies wird alle Duplikat-Gruppen auflösen ohne etwas zu löschen.", "bulk_trash_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} in den Papierkorb verschieben möchtest? Dies wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate in den Papierkorb verschieben.", "buy": "Immich erwerben", @@ -465,6 +467,7 @@ "confirm": "Bestätigen", "confirm_admin_password": "Administrator Passwort bestätigen", "confirm_delete_shared_link": "Bist du sicher, dass du diesen geteilten Link löschen willst?", + "confirm_keep_this_delete_others": "Alle anderen Dateien im Stapel bis auf diese werden gelöscht. Bist du sicher, dass du fortfahren möchten?", "confirm_password": "Passwort bestätigen", "contain": "Vollständig", "context": "Kontext", @@ -510,10 +513,11 @@ "delete": "Löschen", "delete_album": "Album löschen", "delete_api_key_prompt": "Bist du sicher, dass du diesen API-Schlüssel löschen willst?", - "delete_duplicates_confirmation": "Bist du sicher, dass du diese Duplikate dauerhaft löschen willst?", + "delete_duplicates_confirmation": "Bist du sicher, dass du diese Duplikate endgültig löschen willst?", "delete_key": "Schlüssel löschen", "delete_library": "Bibliothek löschen", "delete_link": "Link löschen", + "delete_others": "Andere löschen", "delete_shared_link": "geteilten Link löschen", "delete_tag": "Tag löschen", "delete_tag_confirmation_prompt": "Bist du sicher, dass der Tag {tagName} gelöscht werden soll?", @@ -572,7 +576,7 @@ "editor_crop_tool_h2_rotation": "Drehung", "email": "E-Mail", "empty_trash": "Papierkorb leeren", - "empty_trash_confirmation": "Bist du sicher, dass du den Papierkorb leeren willst?\nDies entfernt alle Dateien im Papierkorb permanent aus Immich und kann nicht rückgängig gemacht werden!", + "empty_trash_confirmation": "Bist du sicher, dass du den Papierkorb leeren willst?\nDies entfernt alle Dateien im Papierkorb endgültig aus Immich und kann nicht rückgängig gemacht werden!", "enable": "Aktivieren", "enabled": "Aktiviert", "end_date": "Enddatum", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Geteilter Link konnte nicht erstellt werden", "failed_to_edit_shared_link": "Geteilter Link konnte nicht bearbeitet werden", "failed_to_get_people": "Personen konnten nicht abgerufen werden", + "failed_to_keep_this_delete_others": "Fehler beim Löschen der anderen Dateien", "failed_to_load_asset": "Fehler beim Laden der Datei", "failed_to_load_assets": "Fehler beim Laden der Dateien", "failed_to_load_people": "Fehler beim Laden von Personen", @@ -787,6 +792,8 @@ "jobs": "Aufgaben", "keep": "Behalten", "keep_all": "Alle behalten", + "keep_this_delete_others": "Dieses behalten, andere löschen", + "kept_this_deleted_others": "Diese Datei behalten und {count, plural, one {# Datei} other {# Dateien}} gelöscht", "keyboard_shortcuts": "Tastenkürzel", "language": "Sprache", "language_setting_description": "Wähle deine bevorzugte Sprache", @@ -940,12 +947,12 @@ "people_feature_description": "Fotos und Videos nach Personen gruppiert durchsuchen", "people_sidebar_description": "Eine Verknüpfung zu Personen in der Seitenleiste anzeigen", "permanent_deletion_warning": "Warnung vor endgültiger Löschung", - "permanent_deletion_warning_setting_description": "Anzeige einer Warnung beim permanenten Löschen von Objekten", - "permanently_delete": "Dauerhaft löschen", - "permanently_delete_assets_count": "{count, plural, one {Datei} other {Dateien}} dauerhaft gelöscht", - "permanently_delete_assets_prompt": "Bist du sicher, dass {count, plural, one {diese Datei} other {diese # Dateien}} dauerhaft gelöscht werden soll? Dadurch {count, plural, one {wird} other {werden}} diese auch aus deinen Alben entfernt.", - "permanently_deleted_asset": "Dauerhaft gelöschtes Objekt", - "permanently_deleted_assets_count": "{count, plural, one {# Datei} other {# Dateien}} dauerhaft gelöscht", + "permanent_deletion_warning_setting_description": "Anzeige einer Warnung beim endgültigen Löschen von Objekten", + "permanently_delete": "Endgültig löschen", + "permanently_delete_assets_count": "{count, plural, one {Datei} other {Dateien}} endgültig löschen", + "permanently_delete_assets_prompt": "Bist du sicher, dass {count, plural, one {diese Datei} other {diese # Dateien}} endgültig gelöscht werden soll? Dadurch {count, plural, one {wird} other {werden}} diese auch aus deinen Alben entfernt.", + "permanently_deleted_asset": "Endgültig gelöschtes Objekt", + "permanently_deleted_assets_count": "{count, plural, one {# Datei} other {# Dateien}} endgültig gelöscht", "person": "Person", "person_hidden": "{name}{hidden, select, true { (verborgen)} other {}}", "photo_shared_all_users": "Es sieht so aus, als hättest du deine Fotos mit allen Benutzern geteilt oder du hast keine Benutzer, mit denen du teilen kannst.", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Sie werden zusammengeführt", "third_party_resources": "Drittanbieter-Quellen", "time_based_memories": "Zeitbasierte Erinnerungen", + "timeline": "Zeitleiste", "timezone": "Zeitzone", "to_archive": "Archivieren", "to_change_password": "Passwort ändern", @@ -1227,6 +1235,7 @@ "to_trash": "In den Papierkorb verschieben", "toggle_settings": "Einstellungen umschalten", "toggle_theme": "Dunkles Theme umschalten", + "total": "Gesamt", "total_usage": "Gesamtnutzung", "trash": "Papierkorb", "trash_all": "Alle löschen", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Kauf verwalten", "user_role_set": "{user} als {role} festlegen", "user_usage_detail": "Nutzungsdetails der Nutzer", + "user_usage_stats": "Statistiken über die Kontonutzung", + "user_usage_stats_description": "Statistiken über die Kontonutzung anzeigen", "username": "Nutzername", "users": "Benutzer", "utilities": "Hilfsmittel", @@ -1297,6 +1308,7 @@ "view_all_users": "Alle Nutzer anzeigen", "view_in_timeline": "In Zeitleiste anzeigen", "view_links": "Links anzeigen", + "view_name": "Ansicht", "view_next_asset": "Nächste Datei anzeigen", "view_previous_asset": "Vorherige Datei anzeigen", "view_stack": "Stapel anzeigen", diff --git a/i18n/el.json b/i18n/el.json index 7357c26b5e..cd97a6d953 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -5,7 +5,7 @@ "acknowledge": "Έλαβα γνώση", "action": "Ενέργεια", "actions": "Ενέργειες", - "active": "Ενεργές", + "active": "Ενεργά", "activity": "Δραστηριότητα", "activity_changed": "Η δραστηριότητα είναι {enabled, select, true {ενεργοποιημένη} other {απενεργοποιημένη}}", "add": "Προσθήκη", @@ -23,6 +23,7 @@ "add_to": "Προσθήκη σε...", "add_to_album": "Προσθήκη σε άλμπουμ", "add_to_shared_album": "Προσθήκη σε κοινόχρηστο άλμπουμ", + "add_url": "Προσθήκη Συνδέσμου", "added_to_archive": "Προστέθηκε στο αρχείο", "added_to_favorites": "Προστέθηκε στα αγαπημένα", "added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα", @@ -50,7 +51,7 @@ "create_job": "Δημιουργία εργασίας", "cron_expression": "Σύνταξη Cron", "cron_expression_description": "Ορίστε το διάστημα σάρωσης χρησιμοποιώντας τη μορφή cron. Για περισσότερες πληροφορίες, ανατρέξτε π.χ. στο Crontab Guru", - "cron_expression_presets": "Προεπιλεγμένες εκφράσεις Cron", + "cron_expression_presets": "Προκαθορισμένες εκφράσεις cron", "disable_login": "Απενεργοποίηση σύνδεσης", "duplicate_detection_job_description": "Εκτελέστε μηχανική μάθηση σε στοιχεία για να εντοπίσετε παρόμοιες εικόνες. Βασίζεται στην Έξυπνη Αναζήτηση", "exclusion_pattern_description": "Τα μοτίβα αποκλεισμού σας επιτρέπουν να αγνοείται αρχεία και φακέλους κατά τη σάρωση της βιβλιοθήκης σας. Αυτό είναι χρήσιμο εάν εχετε φακέλους που περιέχουν αρχεία που δεν θέλετε να εισάγετε, όπως αρχεία RAW.", @@ -67,7 +68,7 @@ "image_prefer_embedded_preview": "Προτίμηση ενσωματωμένης προεπισκόπησης", "image_prefer_embedded_preview_setting_description": "Χρήση ενσωματωμένων προεπισκοπίσεων σε RAW εικόνες ως είσοδο για την επεξεργασία εικόνας εφόσον είναι διαθέσιμες. Αυτό μπορεί να δημιουργήσει πιο ακριβή χρώματα για κάποιες εικόνες, αλλά η ποιότητα των προεπισκοπίσεων εξαρτάται από την κάμερα και ενδέχεται να υπάρχουν περισσότερες αλλοιώσεις στην εικόνα λόγω συμπίεσης.", "image_prefer_wide_gamut": "Προτίμηση ευρέος φάσματος", - "image_prefer_wide_gamut_setting_description": "Χρησιμοποιήστε Display P3 για τις μικρογραφίες. Αυτό διατηρεί καλύτερα την ζωντάνια των χρωμάτων σε εικόνες μεγάλου χρωματικού εύρους, αλλά ενδέχεται να εμφανίζονται αλλιώς σε παλαιότερες συσκευές με παλαιότερες εκδόσεις περιηγητών. Οι εικόνες sRGB μένουν ως έχουν για να αποφευχθούν χρωματικές αλλαγές.", + "image_prefer_wide_gamut_setting_description": "Χρήση Display P3 για τις μικρογραφίες. Αυτό διατηρεί καλύτερα την ζωντάνια των χρωμάτων σε εικόνες μεγάλου χρωματικού εύρους, αλλά ενδέχεται να εμφανίζονται αλλιώς σε παλαιότερες συσκευές με παλαιότερες εκδόσεις περιηγητών. Οι εικόνες sRGB μένουν ως έχουν για να αποφευχθούν χρωματικές αλλαγές.", "image_preview_description": "Μεσαίου μεγέθους εικόνες, χωρίς μεταδεδομένα, οι οποίες χρησιμοποιούνται στην προβολή ενός αντικειμένου και για μηχανική μάθηση", "image_preview_quality_description": "Ποιότητα προεπισκόπησης από 1 έως 100. Όσο μεγαλύτερη τιμή τόσο καλύτερη η ποιότητα, αλλά παράγονται μεγαλύτερα αρχεία που ενδέχεται να μειώσουν την ταχύτητα απόκρισης της εφαρμογής. Οι χαμηλές τιμές μπορεί να επηρεάσουν τη ποιότητα της μηχανικής μάθησης.", "image_preview_title": "Ρυθμίσεις Προεπισκόπισης", @@ -82,9 +83,9 @@ "job_concurrency": "Ταυτόχρονη εκτέλεση {job}", "job_created": "Εργασία δημιουργήθηκε", "job_not_concurrency_safe": "Αυτή η εργασία δεν είναι ασφαλής για ταυτόχρονη εκτέλεση.", - "job_settings": "Ρυθμίσεις Εργασιών", - "job_settings_description": "Διαχείριση ταυτόχρονων εργασιών", - "job_status": "Κατάσταση Εργασιών", + "job_settings": "Ρυθμίσεις Εργασίας", + "job_settings_description": "Διαχείριση ταυτόχρονης εκτέλεσης εργασίας", + "job_status": "Κατάσταση Εργασίας", "jobs_delayed": "{jobCount, plural, one {# καθυστέρησε} other {# καθυστέρησαν}}", "jobs_failed": "{jobCount, plural, one {# απέτυχε} other {# απέτυχαν}}", "library_created": "Δημιουργήθηκε η βιβλιοθήκη: {library}", @@ -95,23 +96,23 @@ "library_scanning_enable_description": "Ενεργοποίηση περιοδικής σάρωσης βιβλιοθήκης", "library_settings": "Εξωτερική Βιβλιοθήκη", "library_settings_description": "Διαχείριση ρυθμίσεων εξωτερικής βιβλιοθήκης", - "library_tasks_description": "Εκτέλεση εργασιών βιβλιοθήκης", + "library_tasks_description": "Εκτελούν εργασίες της βιβλιοθήκης", "library_watching_enable_description": "Παρακολούθηση εξωτερικών βιβλιοθηκών για τροποποιήσεις αρχείων", "library_watching_settings": "Παρακολούθηση βιβλιοθήκης (ΠΕΙΡΑΜΑΤΙΚΟ)", "library_watching_settings_description": "Αυτόματη παρακολούθηση για τροποποιημένα αρχεία", - "logging_enable_description": "Ενεργοποίηση καταγραφής", - "logging_level_description": "Το επίπεδο καταγραφής που θα εφαρμοστεί, όταν αυτή είναι ενεργοποιημένη.", - "logging_settings": "Καταγραφή", + "logging_enable_description": "Ενεργοποίηση καταγραφής συμβάντων", + "logging_level_description": "Το επίπεδο καταγραφής συμβάντων που θα εφαρμοστεί, όταν αυτή είναι ενεργοποιημένη.", + "logging_settings": "Καταγραφή Συμβάντων", "machine_learning_clip_model": "Μοντέλο CLIP", - "machine_learning_clip_model_description": "Το όνομα ενός μοντέλου CLIP που καταγράφεται εδώ. Σημειώστε ότι πρέπει να εκτελέσετε ξανά τη εργασία 'Έξυπνη Αναζήτηση' για όλες τις εικόνες μετά την αλλαγή του μοντέλου.", + "machine_learning_clip_model_description": "Το όνομα ενός μοντέλου CLIP που αναφέρεται εδώ. Σημειώστε ότι πρέπει να επανεκτελέσετε την εργασία 'Έξυπνη Αναζήτηση' για όλες τις εικόνες μετά την αλλαγή μοντέλου.", "machine_learning_duplicate_detection": "Εντοπισμός Διπλότυπων", "machine_learning_duplicate_detection_enabled": "Ενεργοποίηση εντοπισμού διπλότυπων", "machine_learning_duplicate_detection_enabled_description": "Εάν απενεργοποιηθεί, απολύτως παρόμοια στοιχεία θα συνεχίσουν να εκκαθαρίζονται από διπλότυπα.", - "machine_learning_duplicate_detection_setting_description": "Χρησιμοποιήστε τα ενσωματωμένα χαρακτηριστικά του CLIP για να βρείτε πιθανά διπλότυπα", - "machine_learning_enabled": "Ενεργοποίηση μηχανικής εκμάθησης", - "machine_learning_enabled_description": "Εάν απενεργοποιηθεί, όλες οι λειτουργίες μηχανικής εκμάθησης θα απενεργοποιηθούν, ανεξάρτητα από τις παρακάτω ρυθμίσεις.", - "machine_learning_facial_recognition": "Αναγνώριση προσώπου", - "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων σε εικόνες", + "machine_learning_duplicate_detection_setting_description": "Χρησιμοποιήστε τις ενσωματώσεις CLIP για να βρείτε πιθανά διπλότυπα", + "machine_learning_enabled": "Ενεργοποίηση μηχανικής μάθησης", + "machine_learning_enabled_description": "Εάν απενεργοποιηθεί, όλες οι λειτουργίες μηχανικής μάθησης θα απενεργοποιηθούν, ανεξάρτητα από τις παρακάτω ρυθμίσεις.", + "machine_learning_facial_recognition": "Αναγνώριση Προσώπου", + "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων που υπάρχουν σε εικόνες", "machine_learning_facial_recognition_model": "Μοντέλο αναγνώρισης προσώπου", "machine_learning_facial_recognition_model_description": "Τα μοντέλα παρατίθενται με φθίνουσα σειρά μεγέθους. Τα μεγαλύτερα μοντέλα είναι πιο αργά και χρησιμοποιούν περισσότερη μνήμη, αλλά παράγουν καλύτερα αποτελέσματα. Σημειώστε ότι πρέπει να εκτελέσετε ξανά την εργασία Ανίχνευση προσώπου για όλες τις εικόνες κατά την αλλαγή ενός μοντέλου.", "machine_learning_facial_recognition_setting": "Ενεργοποίηση αναγνώρισης προσώπου", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Αναζητήστε εικόνες σημασιολογικά χρησιμοποιώντας ενσωματώσεις CLIP", "machine_learning_smart_search_enabled": "Ενεργοποίηση έξυπνης αναζήτησης", "machine_learning_smart_search_enabled_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για έξυπνη αναζήτηση.", - "machine_learning_url_description": "URL του διακομιστή μηχανικής εκμάθησης", + "machine_learning_url_description": "Η διεύθυνση URL του διακομιστή μηχανικής εκμάθησης. Αν παρέχονται περισσότερες από μία διευθύνσεις URL, τότε, κάθε διακομιστής θα προσπαθήσει να συνδεθεί διαδοχικά, από την πρώτη μέχρι την τελευταία, έως ότου απαντήσει επιτυχώς.", "manage_concurrency": "Διαχείριση ταυτόχρονη εκτέλεσης", "manage_log_settings": "Διαχείριση ρυθμίσεων αρχείου καταγραφής", "map_dark_style": "Σκούρο Θέμα", @@ -222,6 +223,8 @@ "send_welcome_email": "Αποστολή email καλωσορίσματος", "server_external_domain_settings": "Εξωτερική διεύθυνση τομέα", "server_external_domain_settings_description": "Διεύθυνση τομέα για δημόσιους κοινούς συνδέσμους, περιλαμβανομένου του http(s)://", + "server_public_users": "Δημόσιοι Χρήστες", + "server_public_users_description": "Όλοι οι χρήστες (όνομα και email) εμφανίζονται κατά την προσθήκη ενός χρήστη σε κοινόχρηστα άλμπουμ. Όταν αυτή η επιλογή είναι απενεργοποιημένη, η λίστα χρηστών θα είναι διαθέσιμη μόνο στους διαχειριστές.", "server_settings": "Ρυθμίσεις Διακομιστή", "server_settings_description": "Διαχείριση ρυθμίσεων διακομιστή", "server_welcome_message": "Μήνυμα καλωσορίσματος", @@ -247,6 +250,16 @@ "storage_template_user_label": "{label} είναι η Ετικέτα Αποθήκευσης του χρήστη", "system_settings": "Ρυθμίσεις Συστήματος", "tag_cleanup_job": "Καθαρισμός ετικετών", + "template_email_available_tags": "Μπορείτε να χρησιμοποιήσετε τις εξής μεταβλητές στο πρότυπό σας: {tags}", + "template_email_if_empty": "Αν το πρότυπο είναι κενό, θα χρησιμοποιηθεί το προεπιλεγμένο email.", + "template_email_invite_album": "Πρότυπο άλμπουμ πρόσκλησης", + "template_email_preview": "Προεπισκόπηση", + "template_email_settings": "Πρότυπα Email", + "template_email_settings_description": "Διαχείριση προσαρμοσμένων προτύπων ειδοποιήσεων email", + "template_email_update_album": "Ενημέρωση πρότυπου Άλμπουμ", + "template_email_welcome": "Πρότυπο email καλωσορίσματος", + "template_settings": "Πρότυπα ειδοποιήσεων", + "template_settings_description": "Διαχείριση προσαρμοσμένων προτύπων για ειδοποιήσεις.", "theme_custom_css_settings": "Προσαρμοσμένο CSS", "theme_custom_css_settings_description": "Τα Cascading Style Sheets(CSS) επιτρέπει την προσαρμογή του σχεδιασμού του Immich.", "theme_settings": "Ρυθμίσεις Θέματος", @@ -527,7 +540,7 @@ "direction": "Κατεύθυνση", "disabled": "Απενεργοποιημένο", "disallow_edits": "Απαγόρευση επεξεργασιών", - "discord": "Διαφωνία", + "discord": "Discord", "discover": "Ανίχνευση", "dismiss_all_errors": "Παράβλεψη όλων των σφαλμάτων", "dismiss_error": "Παράβλεψη σφάλματος", @@ -721,6 +734,7 @@ "external": "Εξωτερικός", "external_libraries": "Εξωτερικές βιβλιοθήκες", "face_unassigned": "Μη ανατεθειμένο", + "failed_to_load_assets": "Αποτυχία φόρτωσης στοιχείων", "favorite": "Αγαπημένο", "favorite_or_unfavorite_photo": "Ορίστε μία φωτογραφία ως αγαπημένη ή αφαιρέστε την από τα αγαπημένα", "favorites": "Αγαπημένα", @@ -1020,6 +1034,7 @@ "reassigned_assets_to_new_person": "Η ανάθεση {count, plural, one {# αρχείου} other {# αρχείων}} σε νέο άτομο", "reassing_hint": "Ανάθεση των επιλεγμένων στοιχείων σε υπάρχον άτομο", "recent": "Πρόσφατα", + "recent-albums": "Πρόσφατα άλμπουμ", "recent_searches": "Πρόσφατες αναζητήσεις", "refresh": "Ανανέωση", "refresh_encoded_videos": "Ανανέωση κωδικοποιημένων βίντεο", @@ -1041,6 +1056,7 @@ "remove_from_album": "Αφαίρεση από το άλμπουμ", "remove_from_favorites": "Αφαίρεση από τα αγαπημένα", "remove_from_shared_link": "Αφαίρεση από τον κοινόχρηστο σύνδεσμο", + "remove_url": "Αφαίρεση Συνδέσμου", "remove_user": "Αφαίρεση χρήστη", "removed_api_key": "Αφαιρέθηκε το API Key: {name}", "removed_from_archive": "Αφαιρέθηκε/καν από το Αρχείο", @@ -1223,6 +1239,7 @@ "they_will_be_merged_together": "Θα συγχωνευθούν μαζί", "third_party_resources": "Πόροι τρίτων", "time_based_memories": "Μνήμες βασισμένες στο χρόνο", + "timeline": "Χρονολόγιο", "timezone": "Ζώνη ώρας", "to_archive": "Αρχειοθέτηση", "to_change_password": "Αλλαγή κωδικού πρόσβασης", @@ -1232,6 +1249,7 @@ "to_trash": "Κάδος απορριμμάτων", "toggle_settings": "Εναλλαγή ρυθμίσεων", "toggle_theme": "Εναλλαγή θέματος", + "total": "Σύνολο", "total_usage": "Συνολική χρήση", "trash": "Κάδος απορριμμάτων", "trash_all": "Διαγραφή Όλων", @@ -1281,6 +1299,8 @@ "user_purchase_settings_description": "Διαχείριση Αγοράς", "user_role_set": "Ορισμός {user} ως {role}", "user_usage_detail": "Λεπτομέρειες χρήσης του χρήστη", + "user_usage_stats": "Στατιστικά χρήσης λογαριασμού", + "user_usage_stats_description": "Προβολή στατιστικών χρήσης λογαριασμού", "username": "Όνομα Χρήστη", "users": "Χρήστες", "utilities": "Βοηθητικά προγράμματα", @@ -1302,6 +1322,7 @@ "view_all_users": "Προβολή όλων των χρηστών", "view_in_timeline": "Προβολή στο χρονοδιάγραμμα", "view_links": "Προβολή συνδέσμων", + "view_name": "Προβολή", "view_next_asset": "Προβολή επόμενου στοιχείου", "view_previous_asset": "Προβολή προηγούμενου στοιχείου", "view_stack": "Προβολή της στοίβας", diff --git a/i18n/es.json b/i18n/es.json index 84da8a6647..c091b816fe 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -23,6 +23,7 @@ "add_to": "Agregar a...", "add_to_album": "Agregar a un álbum", "add_to_shared_album": "Agregar a un álbum compartido", + "add_url": "Añadir URL", "added_to_archive": "Archivado", "added_to_favorites": "Agregado a favoritos", "added_to_favorites_count": "Agregado {count, number} a favoritos", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Busque imágenes semánticamente utilizando incrustaciones CLIP", "machine_learning_smart_search_enabled": "Habilitar búsqueda inteligente", "machine_learning_smart_search_enabled_description": "Si está deshabilitado, las imágenes no se codificarán para la búsqueda inteligente.", - "machine_learning_url_description": "URL del servidor de aprendizaje automático", + "machine_learning_url_description": "La URL del servidor de aprendizaje automático. Si se proporciona más de una URL, se intentará acceder a cada servidor de uno en uno hasta que uno responda correctamente, en orden del primero al último.", "manage_concurrency": "Ajustes de concurrencia", "manage_log_settings": "Administrar la configuración de los registros", "map_dark_style": "Estilo oscuro", @@ -222,6 +223,8 @@ "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", + "server_public_users": "Usuarios públicos", + "server_public_users_description": "Todos los usuarios (nombre y correo electrónico) aparecen en la lista cuando se añade un usuario a los álbumes compartidos. Si se desactiva, la lista de usuarios sólo estará disponible para los usuarios administradores.", "server_settings": "Configuración del servidor", "server_settings_description": "Administrar la configuración del servidor", "server_welcome_message": "Mensaje de bienvenida", @@ -247,6 +250,16 @@ "storage_template_user_label": "{label} es la etiqueta de almacenamiento del usuario", "system_settings": "Ajustes del Sistema", "tag_cleanup_job": "Limpieza de etiquetas", + "template_email_available_tags": "Puede utilizar las siguientes variables en su plantilla: {tags}", + "template_email_if_empty": "Si la plantilla está vacía, se utilizará el correo electrónico predeterminado.", + "template_email_invite_album": "Plantilla de álbum de invitaciones", + "template_email_preview": "Vista previa", + "template_email_settings": "Modelos de correo electrónico", + "template_email_settings_description": "Gestionar plantillas de notificación por correo electrónico personalizadas", + "template_email_update_album": "Actualizar plantilla del álbum", + "template_email_welcome": "Plantilla de correo electrónico de bienvenida", + "template_settings": "Plantillas de notificación", + "template_settings_description": "Gestione plantillas personalizadas para las notificaciones.", "theme_custom_css_settings": "CSS Personalizado", "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes Tema", @@ -721,6 +734,7 @@ "external": "Externo", "external_libraries": "Bibliotecas Externas", "face_unassigned": "Sin asignar", + "failed_to_load_assets": "Error al cargar los activos", "favorite": "Favorito", "favorite_or_unfavorite_photo": "Foto favorita o no favorita", "favorites": "Favoritos", @@ -1020,6 +1034,7 @@ "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a un nuevo usuario", "reassing_hint": "Asignar archivos seleccionados a una persona existente", "recent": "Reciente", + "recent-albums": "Últimos álbumes", "recent_searches": "Búsquedas recientes", "refresh": "Actualizar", "refresh_encoded_videos": "Recargar los vídeos codificados", @@ -1041,6 +1056,7 @@ "remove_from_album": "Eliminar del álbum", "remove_from_favorites": "Quitar de favoritos", "remove_from_shared_link": "Eliminar desde enlace compartido", + "remove_url": "Eliminar URL", "remove_user": "Eliminar usuario", "removed_api_key": "Clave API eliminada: {name}", "removed_from_archive": "Eliminado del archivo", @@ -1223,6 +1239,7 @@ "they_will_be_merged_together": "Se fusionarán entre sí", "third_party_resources": "Recursos de terceros", "time_based_memories": "Recuerdos basados en tiempo", + "timeline": "Cronología", "timezone": "Zona horaria", "to_archive": "Archivar", "to_change_password": "Cambiar contraseña", @@ -1232,6 +1249,7 @@ "to_trash": "Descartar", "toggle_settings": "Alternar ajustes", "toggle_theme": "Alternar tema oscuro", + "total": "Total", "total_usage": "Uso total", "trash": "Papelera", "trash_all": "Descartar todo", @@ -1281,6 +1299,8 @@ "user_purchase_settings_description": "Gestiona tu compra", "user_role_set": "Carbiar {user} a {role}", "user_usage_detail": "Detalle del uso del usuario", + "user_usage_stats": "Estadísticas de uso de la cuenta", + "user_usage_stats_description": "Ver estadísticas de uso de la cuenta", "username": "Nombre de usuario", "users": "Usuarios", "utilities": "Utilidades", @@ -1302,6 +1322,7 @@ "view_all_users": "Mostrar todos los usuarios", "view_in_timeline": "Mostrar en la línea de tiempo", "view_links": "Mostrar enlaces", + "view_name": "Ver", "view_next_asset": "Mostrar siguiente elemento", "view_previous_asset": "Mostrar elemento anterior", "view_stack": "Ver Pila", diff --git a/i18n/et.json b/i18n/et.json index 817c22eb08..fc2cc3de93 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -23,6 +23,7 @@ "add_to": "Lisa kohta...", "add_to_album": "Lisa albumisse", "add_to_shared_album": "Lisa jagatud albumisse", + "add_url": "Lisa URL", "added_to_archive": "Lisatud arhiivi", "added_to_favorites": "Lisatud lemmikutesse", "added_to_favorites_count": "{count, number} pilti lisatud lemmikutesse", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Otsi pilte semantiliselt CLIP-manuste abil", "machine_learning_smart_search_enabled": "Luba nutiotsing", "machine_learning_smart_search_enabled_description": "Kui keelatud, siis ei kodeerita pilte nutiotsingu jaoks.", - "machine_learning_url_description": "Masinõppe serveri URL", + "machine_learning_url_description": "Masinõppe serveri URL. Kui ette on antud rohkem kui üks URL, proovitakse neid järjest ükshaaval, kuni üks edukalt vastab.", "manage_concurrency": "Halda samaaegsust", "manage_log_settings": "Halda logi seadeid", "map_dark_style": "Tume stiil", @@ -222,6 +223,8 @@ "send_welcome_email": "Saada tervituskiri", "server_external_domain_settings": "Väline domeen", "server_external_domain_settings_description": "Domeen avalikult jagatud linkide jaoks, k.a. http(s)://", + "server_public_users": "Avalikud kasutajad", + "server_public_users_description": "Kasutaja jagatud albumisse lisamisel kuvatakse kõiki kasutajaid (nime ja e-posti aadressiga). Kui keelatud, kuvatakse kasutajate nimekirja ainult administraatoritele.", "server_settings": "Serveri seaded", "server_settings_description": "Halda serveri seadeid", "server_welcome_message": "Tervitusteade", @@ -247,6 +250,16 @@ "storage_template_user_label": "{label} on kasutaja talletussilt", "system_settings": "Süsteemi seaded", "tag_cleanup_job": "Siltide korrastamine", + "template_email_available_tags": "Saad mallis kasutada järgmisi muutujaid: {tags}", + "template_email_if_empty": "Kui mall on tühi, kasutatakse vaikimisi e-kirja.", + "template_email_invite_album": "Albumisse kutse mall", + "template_email_preview": "Eelvaade", + "template_email_settings": "E-posti mallid", + "template_email_settings_description": "Halda e-posti teavitusmalle", + "template_email_update_album": "Albumi muutmise mall", + "template_email_welcome": "Tervituskirja mall", + "template_settings": "Teavituse mallid", + "template_settings_description": "Teavituste mallide haldamine.", "theme_custom_css_settings": "Kohandatud CSS", "theme_custom_css_settings_description": "Cascading Style Sheets lubab Immich'i kujunduse kohandamist.", "theme_settings": "Teema seaded", @@ -719,6 +732,7 @@ "external": "Väline", "external_libraries": "Välised kogud", "face_unassigned": "Seostamata", + "failed_to_load_assets": "Üksuste laadimine ebaõnnestus", "favorite": "Lemmik", "favorites": "Lemmikud", "feature_photo_updated": "Esiletõstetud foto muudetud", @@ -1011,6 +1025,7 @@ "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", + "recent-albums": "Hiljutised albumid", "recent_searches": "Hiljutised otsingud", "refresh": "Värskenda", "refresh_encoded_videos": "Värskenda kodeeritud videod", @@ -1032,6 +1047,7 @@ "remove_from_album": "Eemalda albumist", "remove_from_favorites": "Eemalda lemmikutest", "remove_from_shared_link": "Eemalda jagatud lingist", + "remove_url": "Eemalda URL", "remove_user": "Eemalda kasutaja", "removed_api_key": "API võti eemaldatud: {name}", "removed_from_archive": "Arhiivist eemaldatud", @@ -1210,13 +1226,16 @@ "they_will_be_merged_together": "Nad ühendatakse kokku", "third_party_resources": "Kolmanda osapoole ressursid", "time_based_memories": "Ajapõhised mälestused", + "timeline": "Ajajoon", "timezone": "Ajavöönd", "to_archive": "Arhiivi", "to_change_password": "Muuda parool", "to_favorite": "Lemmik", + "to_login": "Logi sisse", "to_trash": "Prügikasti", "toggle_settings": "Kuva/peida seaded", "toggle_theme": "Lülita tume teema", + "total": "Kokku", "total_usage": "Kogukasutus", "trash": "Prügikast", "trash_all": "Kõik prügikasti", @@ -1262,6 +1281,8 @@ "user_purchase_settings_description": "Halda oma ostu", "user_role_set": "Määra kasutajale {user} roll {role}", "user_usage_detail": "Kasutajate kasutusandmed", + "user_usage_stats": "Konto kasutuse statistika", + "user_usage_stats_description": "Vaata konto kasutuse statistikat", "username": "Kasutajanimi", "users": "Kasutajad", "utilities": "Tööriistad", diff --git a/i18n/fi.json b/i18n/fi.json index 062cc8615b..c2765eb8f4 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -222,6 +222,8 @@ "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", + "server_public_users": "Julkiset käyttäjät", + "server_public_users_description": "Kaikki käyttäjät (nimi ja sähköpostiosoite) luetellaan, kun käyttäjä lisätään jaettuihin albumeihin. Kun toiminto on poistettu käytöstä, käyttäjäluettelo on vain pääkäyttäjien käytettävissä.", "server_settings": "Palvelimen asetukset", "server_settings_description": "Ylläpidä palvelimen asetuksia", "server_welcome_message": "Tervetuloviesti", @@ -465,6 +467,7 @@ "confirm": "Vahvista", "confirm_admin_password": "Vahvista ylläpitäjän salasana", "confirm_delete_shared_link": "Haluatko varmasti poistaa tämän jaetun linkin?", + "confirm_keep_this_delete_others": "Kuvapinon muut kuvat tätä lukuunottamatta poistetaan. Oletko varma, että haluat jatkaa?", "confirm_password": "Vahvista salasana", "contain": "Mahduta", "context": "Konteksti", @@ -514,6 +517,7 @@ "delete_key": "Poista avain", "delete_library": "Poista kirjasto", "delete_link": "Poista linkki", + "delete_others": "Poista muut", "delete_shared_link": "Poista jaettu linkki", "delete_tag": "Poista tunniste", "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa tunnisteen {tagName}?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Jaetun linkin luonti epäonnistui", "failed_to_edit_shared_link": "Jaetun linkin muokkaus epäonnistui", "failed_to_get_people": "Henkilöiden haku epäonnistui", + "failed_to_keep_this_delete_others": "Muiden kohteiden poisto epäonnistui", "failed_to_load_asset": "Kohteen lataus epäonnistui", "failed_to_load_assets": "Kohteiden lataus epäonnistui", "failed_to_load_people": "Henkilöiden lataus epäonnistui", @@ -787,6 +792,8 @@ "jobs": "Taustatehtävät", "keep": "Säilytä", "keep_all": "Säilytä kaikki", + "keep_this_delete_others": "Säilytä tämä, poista muut", + "kept_this_deleted_others": "Tämä kohde säilytettiin. {count, plural, one {# asset} other {# assets}} poistettiin", "keyboard_shortcuts": "Pikanäppäimet", "language": "Kieli", "language_setting_description": "Valitse suosimasi kieli", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Nämä tullaan yhdistämään", "third_party_resources": "Kolmannen osapuolen resurssit", "time_based_memories": "Aikaan perustuvat muistot", + "timeline": "Aikajana", "timezone": "Aikavyöhyke", "to_archive": "Arkistoi", "to_change_password": "Vaihda salasana", @@ -1227,6 +1235,7 @@ "to_trash": "Roskakoriin", "toggle_settings": "Määritä asetukset", "toggle_theme": "Aseta tumma teema", + "total": "Yhteensä", "total_usage": "Käyttö yhteensä", "trash": "Roskakori", "trash_all": "Vie kaikki roskakoriin", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Hallitse ostostasi", "user_role_set": "Tee käyttäjästä {user} {role}", "user_usage_detail": "Käyttäjän käytön tiedot", + "user_usage_stats": "Tilin käyttötilastot", + "user_usage_stats_description": "Näytä tilin käyttötilastot", "username": "Käyttäjänimi", "users": "Käyttäjät", "utilities": "Apuohjelmat", @@ -1297,6 +1308,7 @@ "view_all_users": "Näytä kaikki käyttäjät", "view_in_timeline": "Näytä aikajanalla", "view_links": "Näytä linkit", + "view_name": "Näkymä", "view_next_asset": "Näytä seuraava", "view_previous_asset": "Näytä edellinen", "view_stack": "Näytä pinona", diff --git a/i18n/fr.json b/i18n/fr.json index e226e38fe9..3f7ac6d521 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -5,12 +5,12 @@ "acknowledge": "Compris", "action": "Action", "actions": "Actions", - "active": "En cours d'exécution", + "active": "En cours", "activity": "Activité", "activity_changed": "Activité {enabled, select, true {autorisée} other {interdite}}", "add": "Ajouter", "add_a_description": "Ajouter une description", - "add_a_location": "Ajouter un emplacement", + "add_a_location": "Ajouter une localisation", "add_a_name": "Ajouter un nom", "add_a_title": "Ajouter un titre", "add_exclusion_pattern": "Ajouter un schéma d'exclusion", @@ -23,6 +23,7 @@ "add_to": "Ajouter à…", "add_to_album": "Ajouter à l'album", "add_to_shared_album": "Ajouter à l'album partagé", + "add_url": "Ajouter l'URL", "added_to_archive": "Ajouté à l'archive", "added_to_favorites": "Ajouté aux favoris", "added_to_favorites_count": "{count, number} ajouté(s) aux favoris", @@ -30,7 +31,7 @@ "add_exclusion_pattern_description": "Ajouter des schémas d'exclusion. Les caractères génériques *, ** et ? sont pris en charge. Pour ignorer tous les fichiers dans un répertoire nommé « Raw », utilisez « **/Raw/** ». Pour ignorer tous les fichiers se terminant par « .tif », utilisez « **/*.tif ». Pour ignorer un chemin absolu, utilisez « /chemin/à/ignorer/** ».", "asset_offline_description": "Ce média de la bibliothèque externe n'est plus présent sur le disque et a été déplacé vers la corbeille. Si le fichier a été déplacé dans la bibliothèque, vérifiez votre chronologie pour le nouveau média correspondant. Pour restaurer ce média, veuillez vous assurer que le chemin du fichier ci-dessous peut être accédé par Immich et lancez l'analyse de la bibliothèque.", "authentication_settings": "Paramètres d'authentification", - "authentication_settings_description": "Gérer le mot de passe, la délégation d'authentification OAuth et d'autres paramètres d'authentification", + "authentication_settings_description": "Gérer le mot de passe, l'authentification OAuth et d'autres paramètres d'authentification", "authentication_settings_disable_all": "Êtes-vous sûr de vouloir désactiver toutes les méthodes de connexion ? La connexion sera complètement désactivée.", "authentication_settings_reenable": "Pour réactiver, utilisez une Commande Serveur.", "background_task_job": "Tâches de fond", @@ -39,8 +40,8 @@ "backup_keep_last_amount": "Nombre de sauvegardes à conserver", "backup_settings": "Paramètres de la sauvegarde", "backup_settings_description": "Gérer les paramètres de la sauvegarde", - "check_all": "Vérifier tout", - "cleared_jobs": "Tâches supprimées pour : {job}", + "check_all": "Tout cocher", + "cleared_jobs": "Tâches supprimées pour : {job}", "config_set_by_file": "La configuration est actuellement définie par un fichier de configuration", "confirm_delete_library": "Êtes-vous sûr de vouloir supprimer la bibliothèque {library} ?", "confirm_delete_library_assets": "Êtes-vous sûr de vouloir supprimer cette bibliothèque ? Cette opération supprimera d'Immich {count, plural, one {le média} other {les # médias}} qu'elle contient et ne pourra pas être annulée. Les fichiers resteront sur le disque.", @@ -50,14 +51,14 @@ "create_job": "Créer une tâche", "cron_expression": "Expression cron", "cron_expression_description": "Définir l'intervalle d'analyse à l'aide d'une expression cron. Pour plus d'informations, voir Crontab Guru", - "cron_expression_presets": "Préréglages expression cron", + "cron_expression_presets": "Préréglages d'expression cron", "disable_login": "Désactiver la connexion", - "duplicate_detection_job_description": "Exécution de l'apprentissage automatique sur les médias pour détecter les images similaires. S'appuie sur la recherche intelligente", + "duplicate_detection_job_description": "Lancement de l'apprentissage automatique sur les médias pour détecter les images similaires. Se base sur la recherche intelligente", "exclusion_pattern_description": "Les schémas d'exclusion vous permettent d'ignorer des fichiers et des dossiers lors de l'analyse de votre bibliothèque. Cette fonction est utile si des dossiers contiennent des fichiers que vous ne souhaitez pas importer, tels que des fichiers RAW.", "external_library_created_at": "Bibliothèque externe (créée le {date})", "external_library_management": "Gestion de la bibliothèque externe", "face_detection": "Détection des visages", - "face_detection_description": "Détection des visages dans les médias à l'aide de l'apprentissage automatique. Pour les vidéos, seule la miniature est prise en compte. « Rafraichir» (re)traite tous les médias. « Réinitialise» met en file d'attente les médias qui n'ont pas encore été traités. Les visages détectés seront mis en file d'attente pour la reconnaissance faciale une fois la détection des visages terminée, les regroupant en personnes existantes ou nouvelles.", + "face_detection_description": "Détection des visages dans les médias à l'aide de l'apprentissage automatique. Pour les vidéos, seule la miniature est prise en compte. « Actualiser » (re)traite tous les médias. « Réinitialiser » efface en plus toutes les données actuelles de visages. « Manquants » Les visages détectés seront mis en file d'attente pour la reconnaissance faciale. Une fois la détection des visages terminée, les regroupant en personnes existantes ou nouvelles.", "facial_recognition_job_description": "Regrouper les visages détectés en personnes. Cette étape est exécutée une fois la détection des visages terminée. « Rafraichir» (re)regroupe tous les visages. « Manquant» met en file d'attente les visages auxquels aucune personne n'a été attribuée.", "failed_job_command": "La commande {command} a échoué pour la tâche : {job}", "force_delete_user_warning": "ATTENTION : Cette opération entraîne la suppression immédiate de l'utilisateur et de tous ses médias. Cette opération ne peut être annulée et les fichiers ne peuvent être récupérés.", @@ -89,7 +90,7 @@ "jobs_failed": "{jobCount, plural, other {# en échec}}", "library_created": "Bibliothèque créée : {library}", "library_deleted": "Bibliothèque supprimée", - "library_import_path_description": "Spécifier un dossier à importer. Ce dossier, y compris les sous-dossiers, sera analysé à la recherche d'images et de vidéos.", + "library_import_path_description": "Spécifier un dossier à importer. Ce dossier, y compris ses sous-dossiers, sera analysé à la recherche d'images et de vidéos.", "library_scanning": "Analyse périodique", "library_scanning_description": "Configurer l'analyse périodique de la bibliothèque", "library_scanning_enable_description": "Activer l'analyse périodique de la bibliothèque", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Rechercher des images de manière sémantique en utilisant les intégrations CLIP", "machine_learning_smart_search_enabled": "Activer la recherche intelligente", "machine_learning_smart_search_enabled_description": "Si cette option est désactivée, les images ne seront pas encodées pour la recherche intelligente.", - "machine_learning_url_description": "URL du serveur d'apprentissage automatique", + "machine_learning_url_description": "L’URL du serveur d'apprentissage automatique. Si plusieurs URL sont fournies, chaque serveur sera essayé un par un jusqu’à ce que l’un d’eux réponde avec succès, dans l’ordre de la première à la dernière.", "manage_concurrency": "Gérer du multitâche", "manage_log_settings": "Gérer les paramètres de journalisation", "map_dark_style": "Thème sombre", @@ -189,7 +190,7 @@ "oauth_mobile_redirect_uri_override_description": "Activer quand le fournisseur d'OAuth ne permet pas un URI mobile, comme '{callback} '", "oauth_profile_signing_algorithm": "Algorithme de signature de profil", "oauth_profile_signing_algorithm_description": "Algorithme utilisé pour signer le profil utilisateur.", - "oauth_scope": "Portée", + "oauth_scope": "Périmètre", "oauth_settings": "OAuth", "oauth_settings_description": "Gérer les paramètres de connexion OAuth", "oauth_settings_more_details": "Pour plus de détails sur cette fonctionnalité, consultez ce lien.", @@ -222,6 +223,8 @@ "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", + "server_public_users": "Utilisateurs publics", + "server_public_users_description": "Tous les utilisateurs (nom et courriel) sont listés lors de l'ajout d'un utilisateur à des albums partagés. Quand cela est désactivé, la liste des utilisateurs est uniquement disponible pour les comptes administrateurs.", "server_settings": "Paramètres du serveur", "server_settings_description": "Gérer les paramètres du serveur", "server_welcome_message": "Message de bienvenue", @@ -247,11 +250,21 @@ "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", "tag_cleanup_job": "Nettoyage des étiquettes", + "template_email_available_tags": "Vous pouvez utiliser les variables suivantes dans votre modèle : {tags}", + "template_email_if_empty": "Si le modèle est vide, l’e-mail par défaut sera utilisé.", + "template_email_invite_album": "Modèle d'invitation à un album", + "template_email_preview": "Prévisualiser", + "template_email_settings": "Modèles de courriel", + "template_email_settings_description": "Gérer les modèles de notifications par courriel personnalisés", + "template_email_update_album": "Mettre à jour le modèle d’album", + "template_email_welcome": "Modèle de courriel de bienvenue", + "template_settings": "Modèles de notifications", + "template_settings_description": "Gérer les modèles personnalisés pour les notifications.", "theme_custom_css_settings": "CSS personnalisé", "theme_custom_css_settings_description": "Les feuilles de style en cascade (CSS) permettent de personnaliser l'apparence d'Immich.", "theme_settings": "Paramètres du thème", "theme_settings_description": "Gérer la personnalisation de l'interface web d'Immich", - "these_files_matched_by_checksum": "Ces fichiers correspondent par leur somme de contrôle", + "these_files_matched_by_checksum": "Ces fichiers sont identiques d'après leur somme de contrôle", "thumbnail_generation_job": "Génération des miniatures", "thumbnail_generation_job_description": "Génération des miniatures pour chaque média ainsi que pour les visages détectés", "transcoding_acceleration_api": "API d'accélération", @@ -262,7 +275,7 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codecs audio acceptés", "transcoding_accepted_audio_codecs_description": "Sélectionnez les codecs audio qui n'ont pas besoin d'être transcodés. Utilisé uniquement pour certaines politiques de transcodage.", - "transcoding_accepted_containers": "Containers acceptés", + "transcoding_accepted_containers": "Conteneurs acceptés", "transcoding_accepted_containers_description": "Sélectionnez les formats de conteneurs qui n'ont pas besoin d'être remuxés en MP4. Utilisé uniquement pour certaines politiques de transcodage.", "transcoding_accepted_video_codecs": "Codecs vidéo acceptés", "transcoding_accepted_video_codecs_description": "Sélectionnez les codecs vidéo qui n'ont pas besoin d'être transcodés. Utilisé uniquement pour certaines politiques de transcodage.", @@ -299,7 +312,7 @@ "transcoding_settings_description": "Gérer les informations de résolution et d'encodage des fichiers vidéo", "transcoding_target_resolution": "Résolution cible", "transcoding_target_resolution_description": "Des résolutions plus élevées peuvent préserver plus de détails, mais prennent plus de temps à encoder, ont de plus grandes tailles de fichiers, et peuvent réduire la réactivité de l'application.", - "transcoding_temporal_aq": "AQ temporelle", + "transcoding_temporal_aq": "Quantification adaptative temporelle (temporal AQ)", "transcoding_temporal_aq_description": "S'applique uniquement à NVENC. Améliore la qualité des scènes riches en détails et à faible mouvement. Peut ne pas être compatible avec les anciens appareils.", "transcoding_threads": "Processus", "transcoding_threads_description": "Une valeur plus élevée entraîne un encodage plus rapide, mais laisse moins de place au serveur pour traiter d'autres tâches pendant son activité. Cette valeur ne doit pas être supérieure au nombre de cœurs de CPU. Une valeur égale à 0 maximise l'utilisation.", @@ -392,7 +405,7 @@ "asset_adding_to_album": "Ajout à l'album...", "asset_description_updated": "La description du média a été mise à jour", "asset_filename_is_offline": "Le média {filename} est hors ligne", - "asset_has_unassigned_faces": "Le média a des visages non assignés", + "asset_has_unassigned_faces": "Le média a des visages non attribués", "asset_hashing": "Hachage...", "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média externe n'est plus accessible sur le disque. Veuillez contacter votre administrateur Immich pour obtenir de l'aide.", @@ -465,6 +478,7 @@ "confirm": "Confirmer", "confirm_admin_password": "Confirmer le mot de passe Admin", "confirm_delete_shared_link": "Voulez-vous vraiment supprimer ce lien partagé ?", + "confirm_keep_this_delete_others": "Tous les autres médias dans la pile seront supprimés sauf celui-ci. Êtes-vous sûr de vouloir continuer ?", "confirm_password": "Confirmer le mot de passe", "contain": "Contenu", "context": "Contexte", @@ -514,6 +528,7 @@ "delete_key": "Supprimer la clé", "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", + "delete_others": "Supprimer les autres", "delete_shared_link": "Supprimer le lien partagé", "delete_tag": "Supprimer l'étiquette", "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer l'étiquette {tagName} ?", @@ -532,12 +547,12 @@ "display_options": "Afficher les options", "display_order": "Ordre d'affichage", "display_original_photos": "Afficher les photos originales", - "display_original_photos_setting_description": "Préférer afficher la photo originale lors de la visualisation d'un média plutôt que sa miniature lorsque cela est possible. Cela peut entraîner des vitesses d'affichage plus lentes.", + "display_original_photos_setting_description": "Afficher de préférence la photo originale lors de la visualisation d'un média plutôt que sa miniature lorsque cela est possible. Cela peut entraîner des vitesses d'affichage plus lentes.", "do_not_show_again": "Ne plus afficher ce message", "documentation": "Documentation", "done": "Terminé", "download": "Télécharger", - "download_include_embedded_motion_videos": "Vidéos embarquées", + "download_include_embedded_motion_videos": "Vidéos intégrées", "download_include_embedded_motion_videos_description": "Inclure des vidéos intégrées dans les photos de mouvement comme un fichier séparé", "download_settings": "Télécharger", "download_settings_description": "Gérer les paramètres de téléchargement des médias", @@ -562,7 +577,7 @@ "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", "edit_tag": "Modifier l'étiquette", - "edit_title": "Modifier le title", + "edit_title": "Modifier le titre", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", "editor": "Editeur", @@ -585,15 +600,15 @@ "cant_apply_changes": "Impossible d'appliquer les changements", "cant_change_activity": "Impossible {enabled, select, true {d'interdire} other {d'autoriser}} l'activité", "cant_change_asset_favorite": "Impossible de changer le favori du média", - "cant_change_metadata_assets_count": "Impossible de modifier les métadonnées de {count, plural, one {# média} other {# médias}}", - "cant_get_faces": "Impossible d'obtenir de visages", + "cant_change_metadata_assets_count": "Impossible de modifier les métadonnées {count, plural, one {d'un média} other {de # médias}}", + "cant_get_faces": "Impossible d'obtenir des visages", "cant_get_number_of_comments": "Impossible d'obtenir le nombre de commentaires", "cant_search_people": "Impossible de rechercher des personnes", "cant_search_places": "Impossible de rechercher des lieux", "cleared_jobs": "Tâches supprimées pour : {job}", "error_adding_assets_to_album": "Erreur lors de l'ajout des médias à l'album", "error_adding_users_to_album": "Erreur lors de l'ajout d'utilisateurs à l'album", - "error_deleting_shared_user": "Erreur lors de la suppression l'utilisateur partagé", + "error_deleting_shared_user": "Erreur lors de la suppression de l'utilisateur partagé", "error_downloading": "Erreur lors du téléchargement de {filename}", "error_hiding_buy_button": "Impossible de masquer le bouton d'achat", "error_removing_assets_from_album": "Erreur lors de la suppression des médias de l'album, vérifier la console pour plus de détails", @@ -604,6 +619,7 @@ "failed_to_create_shared_link": "Impossible de créer le lien partagé", "failed_to_edit_shared_link": "Impossible de modifier le lien partagé", "failed_to_get_people": "Impossible d'obtenir les personnes", + "failed_to_keep_this_delete_others": "Impossible de conserver ce média et de supprimer les autres médias", "failed_to_load_asset": "Impossible de charger le média", "failed_to_load_assets": "Impossible de charger les médias", "failed_to_load_people": "Impossible de charger les personnes", @@ -637,21 +653,21 @@ "unable_to_copy_to_clipboard": "Impossible de copier dans le presse-papiers, assurez-vous que vous accédez à la page via https", "unable_to_create_admin_account": "Impossible de créer le compte administrateur", "unable_to_create_api_key": "Impossible de créer une nouvelle clé API", - "unable_to_create_library": "Création de bibliothèque impossible", - "unable_to_create_user": "Création de l'utilisateur impossible", - "unable_to_delete_album": "Suppression de l'album impossible", - "unable_to_delete_asset": "Suppression du média impossible", + "unable_to_create_library": "Impossible de créer la bibliothèque", + "unable_to_create_user": "Impossible de créer l'utilisateur", + "unable_to_delete_album": "Impossible de supprimer l'album", + "unable_to_delete_asset": "Impossible de supprimer le média", "unable_to_delete_assets": "Erreur lors de la suppression des médias", - "unable_to_delete_exclusion_pattern": "Suppression du modèle d'exclusion impossible", - "unable_to_delete_import_path": "Suppression du chemin d'importation impossible", - "unable_to_delete_shared_link": "Suppression du lien de partage impossible", - "unable_to_delete_user": "Suppression de l'utilisateur impossible", + "unable_to_delete_exclusion_pattern": "Impossible de supprimer le modèle d'exclusion", + "unable_to_delete_import_path": "Impossible de supprimer le chemin d'importation", + "unable_to_delete_shared_link": "Impossible de supprimer le lien de partage", + "unable_to_delete_user": "Impossible de supprimer l'utilisateur", "unable_to_download_files": "Impossible de télécharger les fichiers", - "unable_to_edit_exclusion_pattern": "Modification du modèle d'exclusion impossible", - "unable_to_edit_import_path": "Modification du chemin d'importation impossible", + "unable_to_edit_exclusion_pattern": "Impossible de modifier le modèle d'exclusion", + "unable_to_edit_import_path": "Impossible de modifier le chemin d'importation", "unable_to_empty_trash": "Impossible de vider la corbeille", "unable_to_enter_fullscreen": "Mode plein écran indisponible", - "unable_to_exit_fullscreen": "Sortie du mode plein écran impossible", + "unable_to_exit_fullscreen": "Impossible de sortir du mode plein écran", "unable_to_get_comments_number": "Impossible d'obtenir le nombre de commentaires", "unable_to_get_shared_link": "Échec de la récupération du lien partagé", "unable_to_hide_person": "Impossible de cacher la personne", @@ -665,8 +681,8 @@ "unable_to_log_out_device": "Impossible de déconnecter l'appareil", "unable_to_login_with_oauth": "Impossible de se connecter avec OAuth", "unable_to_play_video": "Impossible de jouer la vidéo", - "unable_to_reassign_assets_existing_person": "Incapable de réaffecter des médias à {name, select, null {une personne existante} other {{name}}}", - "unable_to_reassign_assets_new_person": "Impossible de réaffecter les médias à une nouvelle personne", + "unable_to_reassign_assets_existing_person": "Impossible de réattribuer les médias à {name, select, null {une personne existante} other {{name}}}", + "unable_to_reassign_assets_new_person": "Impossible de réattribuer les médias à une nouvelle personne", "unable_to_refresh_user": "Impossible d'actualiser l'utilisateur", "unable_to_remove_album_users": "Impossible de supprimer les utilisateurs de l'album", "unable_to_remove_api_key": "Impossible de supprimer la clé API", @@ -685,7 +701,7 @@ "unable_to_save_api_key": "Impossible de sauvegarder la clé API", "unable_to_save_date_of_birth": "Impossible de sauvegarder la date de naissance", "unable_to_save_name": "Impossible de sauvegarder le nom", - "unable_to_save_profile": "Impossible de sauvegarder le profile", + "unable_to_save_profile": "Impossible de sauvegarder le profil", "unable_to_save_settings": "Impossible d'enregistrer les préférences", "unable_to_scan_libraries": "Impossible de scanner les bibliothèques", "unable_to_scan_library": "Impossible de scanner la bibliothèque", @@ -716,8 +732,9 @@ "export_as_json": "Exporter en JSON", "extension": "Extension", "external": "Externe", - "external_libraries": "Bibliothèques ext.", + "external_libraries": "Bibliothèques externes", "face_unassigned": "Non attribué", + "failed_to_load_assets": "Échec du chargement des ressources", "favorite": "Favori", "favorite_or_unfavorite_photo": "Ajouter ou supprimer des favoris", "favorites": "Favoris", @@ -787,6 +804,8 @@ "jobs": "Tâches", "keep": "Conserver", "keep_all": "Les conserver tous", + "keep_this_delete_others": "Conserver celui-ci, supprimer les autres", + "kept_this_deleted_others": "Ce média a été conservé, et {count, plural, one {un autre a été supprimé} other {# autres ont été supprimés}}", "keyboard_shortcuts": "Raccourcis clavier", "language": "Langue", "language_setting_description": "Sélectionnez votre langue préférée", @@ -836,7 +855,7 @@ "media_type": "Type de média", "memories": "Souvenirs", "memories_setting_description": "Gérer ce que vous voyez dans vos souvenirs", - "memory": "Mémoire", + "memory": "Souvenir", "memory_lane_title": "Fil de souvenirs {title}", "menu": "Menu", "merge": "Fusionner", @@ -942,7 +961,7 @@ "permanent_deletion_warning": "Avertissement avant suppression définitive", "permanent_deletion_warning_setting_description": "Afficher un avertissement avant la suppression définitive d'un média", "permanently_delete": "Supprimer définitivement", - "permanently_delete_assets_count": "Suppression définitive de {count, plural, one {média} other {médias}}", + "permanently_delete_assets_count": "Suppression définitive {count, plural, one {du média} other {des médias}}", "permanently_delete_assets_prompt": "Êtes-vous sûr de vouloir supprimer définitivement {count, plural, one {ce média ?} other {ces # médias ?}} Cela {count, plural, one {le} other {les}} supprimera aussi de {count, plural, one {son (ses)} other {leur(s)}} album(s).", "permanently_deleted_asset": "Média supprimé définitivement", "permanently_deleted_assets_count": "{count, plural, one {# média définitivement supprimé} other {# médias définitivement supprimés}}", @@ -973,7 +992,7 @@ "public_album": "Album public", "public_share": "Partage public", "purchase_account_info": "Contributeur", - "purchase_activated_subtitle": "Merci d'avoir apporté votre soutien à Immich et les logiciels open source", + "purchase_activated_subtitle": "Merci d'avoir apporté votre soutien à Immich et aux logiciels open source", "purchase_activated_time": "Activé le {date, date}", "purchase_activated_title": "Votre clé a été activée avec succès", "purchase_button_activate": "Activer", @@ -983,7 +1002,7 @@ "purchase_button_reminder": "Me le rappeler dans 30 jours", "purchase_button_remove_key": "Supprimer la clé", "purchase_button_select": "Sélectionner", - "purchase_failed_activation": "Erreur à l'activation. Veuillez vérifier votre e-mail pour obtenir la clé du produit correcte !", + "purchase_failed_activation": "Erreur à l'activation. Veuillez vérifier votre courriel pour obtenir la clé du produit correcte !", "purchase_individual_description_1": "Pour un utilisateur", "purchase_individual_description_2": "Statut de contributeur", "purchase_individual_title": "Utilisateur", @@ -992,14 +1011,14 @@ "purchase_lifetime_description": "Achat à vie", "purchase_option_title": "OPTIONS D'ACHAT", "purchase_panel_info_1": "Développer Immich nécessite du temps et de l'énergie, et nous avons des ingénieurs qui travaillent à plein temps pour en faire le meilleur produit possible. Notre mission est de générer, pour les logiciels open source et les pratiques de travail éthique, une source de revenus suffisante pour les développeurs et de créer un écosystème respectueux de la vie privée grâce a des alternatives crédibles aux services cloud peu scrupuleux.", - "purchase_panel_info_2": "Étant donné que nous nous engageons à ne pas ajouter de murs de paiement, cet achat ne vous donnera pas de fonctionnalités supplémentaires dans Immich. Nous comptons sur des utilisateurs comme vous pour soutenir le développement continu d'Immich.", + "purchase_panel_info_2": "Étant donné que nous nous engageons à ne pas ajouter de fonctionnalités payantes, cet achat ne vous donnera pas de fonctionnalités supplémentaires dans Immich. Nous comptons sur des utilisateurs comme vous pour soutenir le développement continu d'Immich.", "purchase_panel_title": "Soutenir le projet", "purchase_per_server": "Par serveur", "purchase_per_user": "Par utilisateur", "purchase_remove_product_key": "Supprimer la clé du produit", "purchase_remove_product_key_prompt": "Êtes-vous sûr de vouloir supprimer la clé du produit ?", "purchase_remove_server_product_key": "Supprimer la clé du produit pour le Serveur", - "purchase_remove_server_product_key_prompt": "Êtes-vous sûr de vouloir supprimer la clé du produit pour le serveur ?", + "purchase_remove_server_product_key_prompt": "Êtes-vous sûr de vouloir supprimer la clé du produit pour le Serveur ?", "purchase_server_description_1": "Pour l'ensemble du serveur", "purchase_server_description_2": "Statut de contributeur", "purchase_server_title": "Serveur", @@ -1010,23 +1029,24 @@ "rating_description": "Afficher l'évaluation EXIF dans le panneau d'information", "reaction_options": "Options de réaction", "read_changelog": "Lire les changements", - "reassign": "Réaffecter", - "reassigned_assets_to_existing_person": "{count, plural, one {# média réaffecté} other {# médias réaffectés}} à {name, select, null {une personne existante} other {{name}}}", - "reassigned_assets_to_new_person": "{count, plural, one {# média réassigné} other {# médias réassignés}} à une nouvelle personne", + "reassign": "Réattribuer", + "reassigned_assets_to_existing_person": "{count, plural, one {# média réattribué} other {# médias réattribués}} à {name, select, null {une personne existante} other {{name}}}", + "reassigned_assets_to_new_person": "{count, plural, one {# média réattribué} other {# médias réattribués}} à une nouvelle personne", "reassing_hint": "Attribuer ces médias à une personne existante", "recent": "Récent", + "recent-albums": "Albums récents", "recent_searches": "Recherches récentes", "refresh": "Actualiser", "refresh_encoded_videos": "Actualiser les vidéos encodées", - "refresh_faces": "Mettre à jour les visages", + "refresh_faces": "Actualiser les visages", "refresh_metadata": "Actualiser les métadonnées", "refresh_thumbnails": "Actualiser les vignettes", "refreshed": "Actualisé", "refreshes_every_file": "Actualise tous les fichiers (existants et nouveaux)", "refreshing_encoded_video": "Actualisation de la vidéo encodée", - "refreshing_faces": "Actualiser les visages", + "refreshing_faces": "Actualisation des visages", "refreshing_metadata": "Actualisation des métadonnées", - "regenerating_thumbnails": "Régénération des vignettes", + "regenerating_thumbnails": "Regénération des vignettes", "remove": "Supprimer", "remove_assets_album_confirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, one {# média} other {# médias}} de l'album ?", "remove_assets_shared_link_confirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, one {# média} other {# médias}} de ce lien partagé ?", @@ -1036,6 +1056,7 @@ "remove_from_album": "Supprimer de l'album", "remove_from_favorites": "Supprimer des favoris", "remove_from_shared_link": "Supprimer des liens partagés", + "remove_url": "Supprimer l'URL", "remove_user": "Supprimer l'utilisateur", "removed_api_key": "Clé API supprimée : {name}", "removed_from_archive": "Supprimé de l'archive", @@ -1116,7 +1137,7 @@ "send_welcome_email": "Envoyer un courriel de bienvenue", "server_offline": "Serveur hors ligne", "server_online": "Serveur en ligne", - "server_stats": "Statistiques Serveur", + "server_stats": "Statistiques du serveur", "server_version": "Version du serveur", "set": "Définir", "set_as_album_cover": "Définir comme couverture d'album", @@ -1138,14 +1159,14 @@ "shared_with_partner": "Partagé avec {partner}", "sharing": "Partage", "sharing_enter_password": "Veuillez saisir le mot de passe pour visualiser cette page.", - "sharing_sidebar_description": "Afficher un lien vers Partage dans la barre latérale", + "sharing_sidebar_description": "Afficher un lien vers Partager dans la barre latérale", "shift_to_permanent_delete": "appuyez sur ⇧ pour supprimer définitivement le média", "show_album_options": "Afficher les options de l'album", "show_albums": "Montrer les albums", "show_all_people": "Montrer toutes les personnes", "show_and_hide_people": "Afficher / Masquer les personnes", "show_file_location": "Afficher l'emplacement du fichier", - "show_gallery": "Afficher la gallerie", + "show_gallery": "Afficher la galerie", "show_hidden_people": "Afficher les personnes masquées", "show_in_timeline": "Afficher dans la vue chronologique", "show_in_timeline_setting_description": "Afficher les photos et vidéos de cet utilisateur dans votre vue chronologique", @@ -1197,19 +1218,19 @@ "storage_usage": "{used} sur {available} utilisé", "submit": "Soumettre", "suggestions": "Suggestions", - "sunrise_on_the_beach": "Aurore sur la plage", + "sunrise_on_the_beach": "Lever de soleil sur la plage", "support": "Support", "support_and_feedback": "Support & Retours", "support_third_party_description": "Votre installation d'Immich est packagée via une application tierce. Si vous rencontrez des anomalies, elles peuvent venir de ce packaging tiers, merci de créer les anomalies avec ces tiers en premier lieu en utilisant les liens ci-dessous.", "swap_merge_direction": "Inverser la direction de fusion", "sync": "Synchroniser", - "tag": "Tag", - "tag_assets": "Taguer les médias", + "tag": "Étiquette", + "tag_assets": "Étiqueter les médias", "tag_created": "Étiquette créée : {tag}", "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", "tag_not_found_question": "Vous ne trouvez pas une étiquette ? Créer une nouvelle étiquette.", "tag_updated": "Étiquette mise à jour : {tag}", - "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", + "tagged_assets": "Étiquette ajoutée à {count, plural, one {# média} other {# médias}}", "tags": "Étiquettes", "template": "Modèle", "theme": "Thème", @@ -1218,6 +1239,7 @@ "they_will_be_merged_together": "Elles seront fusionnées ensemble", "third_party_resources": "Ressources tierces", "time_based_memories": "Souvenirs basés sur la date", + "timeline": "Vue chronologique", "timezone": "Fuseau horaire", "to_archive": "Archiver", "to_change_password": "Modifier le mot de passe", @@ -1227,11 +1249,12 @@ "to_trash": "Corbeille", "toggle_settings": "Inverser les paramètres", "toggle_theme": "Inverser le thème sombre", + "total": "Total", "total_usage": "Utilisation globale", "trash": "Corbeille", "trash_all": "Tout supprimer", "trash_count": "Corbeille {count, number}", - "trash_delete_asset": "Corbeille/Suppression d'un média", + "trash_delete_asset": "Mettre à la corbeille/Supprimer un média", "trash_no_results_message": "Les photos et vidéos supprimées s'afficheront ici.", "trashed_items_will_be_permanently_deleted_after": "Les éléments dans la corbeille seront supprimés définitivement après {days, plural, one {# jour} other {# jours}}.", "type": "Type", @@ -1276,6 +1299,8 @@ "user_purchase_settings_description": "Gérer votre achat", "user_role_set": "Définir {user} comme {role}", "user_usage_detail": "Détail de l'utilisation des utilisateurs", + "user_usage_stats": "Statistiques d'utilisation du compte", + "user_usage_stats_description": "Voir les statistiques d'utilisation du compte", "username": "Nom d'utilisateur", "users": "Utilisateurs", "utilities": "Utilitaires", @@ -1297,6 +1322,7 @@ "view_all_users": "Voir tous les utilisateurs", "view_in_timeline": "Voir dans la vue chronologique", "view_links": "Voir les liens", + "view_name": "Vue", "view_next_asset": "Voir le média suivant", "view_previous_asset": "Voir le média précédent", "view_stack": "Afficher la pile", @@ -1305,7 +1331,7 @@ "warning": "Attention", "week": "Semaine", "welcome": "Bienvenue", - "welcome_to_immich": "Bienvenue sur immich", + "welcome_to_immich": "Bienvenue sur Immich", "year": "Année", "years_ago": "Il y a {years, plural, one {# an} other {# ans}}", "yes": "Oui", diff --git a/i18n/he.json b/i18n/he.json index 4c676315cd..c2f382e49c 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -1,5 +1,5 @@ { - "about": "אודות", + "about": "רענן", "account": "חשבון", "account_settings": "הגדרות חשבון", "acknowledge": "הבנתי", @@ -23,6 +23,7 @@ "add_to": "הוסף ל..", "add_to_album": "הוסף לאלבום", "add_to_shared_album": "הוסף לאלבום משותף", + "add_url": "הוספת קישור", "added_to_archive": "נוסף לארכיון", "added_to_favorites": "נוסף למועדפים", "added_to_favorites_count": "{count, number} נוספו למועדפים", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "חפש תמונות באופן סמנטי באמצעות הטמעות של CLIP", "machine_learning_smart_search_enabled": "אפשר חיפוש חכם", "machine_learning_smart_search_enabled_description": "אם מושבת, תמונות לא יקודדו לחיפוש חכם.", - "machine_learning_url_description": "כתובת האתר של שרת למידת המכונה", + "machine_learning_url_description": "כתובת האתר של שרת למידת המכונה. אם ניתן יותר מכתוובת אחת, כל שרת ינסה בתורו עד אשר יענה בחיוב, בסדר התחלתי.", "manage_concurrency": "נהל בו-זמניות", "manage_log_settings": "נהל הגדרות רישום ביומן", "map_dark_style": "עיצוב כהה", @@ -222,6 +223,8 @@ "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", + "server_public_users": "משתמשים ציבוריים", + "server_public_users_description": "כל המשתמשים (שם ודוא\"ל) מופיעים בעת הוספת משתמש לאלבומים משותפים. כאשר התכונה מושבתת, רשימת המשתמשים תהיה זמינה רק למשתמשים בעלי הרשאות מנהל.", "server_settings": "הגדרות שרת", "server_settings_description": "נהל הגדרות שרת", "server_welcome_message": "הודעת פתיחה", @@ -421,7 +424,7 @@ "blurred_background": "רקע מטושטש", "bugs_and_feature_requests": "באגים & בקשות לתכונות", "build": "Build", - "build_image": "Build Image", + "build_image": "בניית גרסה", "bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!", "bulk_keep_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להשאיר {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה יפתור את כל הקבוצות הכפולות מבלי למחוק דבר.", "bulk_trash_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להעביר לאשפה בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הגדול ביותר של כל קבוצה ויעביר לאשפה את כל שאר הכפילויות.", @@ -465,6 +468,7 @@ "confirm": "אישור", "confirm_admin_password": "אשר סיסמת מנהל", "confirm_delete_shared_link": "האם את/ה בטוח/ה שברצונך למחוק את הקישור המשותף הזה?", + "confirm_keep_this_delete_others": "כל שאר הנכסים בערימה יימחקו למעט נכס זה. האם את/ה בטוח/ה שברצונך להמשיך?", "confirm_password": "אשר סיסמה", "contain": "מכיל", "context": "הקשר", @@ -514,6 +518,7 @@ "delete_key": "מחק מפתח", "delete_library": "מחק ספרייה", "delete_link": "מחק קישור", + "delete_others": "מחק אחרים", "delete_shared_link": "מחק קישור משותף", "delete_tag": "מחק תג", "delete_tag_confirmation_prompt": "האם את/ה בטוח/ה שברצונך למחוק תג {tagName}?", @@ -604,6 +609,7 @@ "failed_to_create_shared_link": "יצירת קישור משותף נכשלה", "failed_to_edit_shared_link": "עריכת קישור משותף נכשלה", "failed_to_get_people": "קבלת אנשים נכשלה", + "failed_to_keep_this_delete_others": "נכשל לשמור את הנכס הזה ולמחוק את הנכסים האחרים", "failed_to_load_asset": "טעינת נכס נכשלה", "failed_to_load_assets": "טעינת נכסים נכשלה", "failed_to_load_people": "נכשל באחזור אנשים", @@ -787,6 +793,8 @@ "jobs": "משימות", "keep": "שמור", "keep_all": "שמור הכל", + "keep_this_delete_others": "שמור על זה, מחק אחרים", + "kept_this_deleted_others": "נכס זה נשמר ונמחקו {count, plural, one {נכס #} other {# נכסים}}", "keyboard_shortcuts": "קיצורי מקלדת", "language": "שפה", "language_setting_description": "בחר/י את השפה המועדפת עליך", @@ -1218,6 +1226,7 @@ "they_will_be_merged_together": "הם יתמזגו יחד", "third_party_resources": "משאבי צד שלישי", "time_based_memories": "זכרונות מבוססי זמן", + "timeline": "ציר זמן", "timezone": "אזור זמן", "to_archive": "העבר לארכיון", "to_change_password": "שנה סיסמה", @@ -1227,6 +1236,7 @@ "to_trash": "אשפה", "toggle_settings": "החלף מצב הגדרות", "toggle_theme": "החלף ערכת נושא כהה", + "total": "סה\"כ", "total_usage": "שימוש כולל", "trash": "אשפה", "trash_all": "העבר הכל לאשפה", @@ -1276,6 +1286,8 @@ "user_purchase_settings_description": "נהל את הרכישה שלך", "user_role_set": "הגדר את {user} בתור {role}", "user_usage_detail": "פרטי השימוש של המשתמש", + "user_usage_stats": "סטטיסטיקות שימוש בחשבון", + "user_usage_stats_description": "הצג סטטיסטיקות שימוש בחשבון", "username": "שם משתמש", "users": "משתמשים", "utilities": "כלים", @@ -1297,6 +1309,7 @@ "view_all_users": "הצג את כל המשתמשים", "view_in_timeline": "ראה בציר הזמן", "view_links": "הצג קישורים", + "view_name": "לצפות", "view_next_asset": "הצג את הנכס הבא", "view_previous_asset": "הצג את הנכס הקודם", "view_stack": "הצג ערימה", diff --git a/i18n/hr.json b/i18n/hr.json index 3cf4c7d554..d4273b8741 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -34,6 +34,11 @@ "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", "authentication_settings_reenable": "Za ponovno uključivanje upotrijebite naredbu poslužitelja.", "background_task_job": "Pozadinski zadaci", + "backup_database": "Sigurnosna kopija baze podataka", + "backup_database_enable_description": "Omogućite sigurnosne kopije baze podataka", + "backup_keep_last_amount": "Količina prethodnih sigurnosnih kopija za čuvanje", + "backup_settings": "Postavke sigurnosne kopije", + "backup_settings_description": "Upravljanje postavkama sigurnosne kopije baze podataka", "check_all": "Provjeri sve", "cleared_jobs": "Izbrisani poslovi za: {job}", "config_set_by_file": "Konfiguracija je trenutno postavljena konfiguracijskom datotekom", @@ -43,6 +48,9 @@ "confirm_reprocess_all_faces": "Jeste li sigurni da želite ponovno obraditi sva lica? Ovo će također obrisati imenovane osobe.", "confirm_user_password_reset": "Jeste li sigurni da želite poništiti lozinku korisnika {user}?", "create_job": "Izradi zadatak", + "cron_expression": "Cron izraz (expression)", + "cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", + "cron_expression_presets": "Cron unaprijed postavljene postavke izraza", "disable_login": "Onemogući prijavu", "duplicate_detection_job_description": "Pokrenite strojno učenje na materijalima kako biste otkrili slične slike. Oslanja se na Pametno Pretraživanje", "exclusion_pattern_description": "Uzorci izuzimanja omogućuju vam da zanemarite datoteke i mape prilikom skeniranja svoje biblioteke. Ovo je korisno ako imate mape koje sadrže datoteke koje ne želite uvesti, kao što su RAW datoteke.", @@ -62,9 +70,15 @@ "image_prefer_wide_gamut_setting_description": "Koristite Display P3 za sličice. Ovo bolje čuva živost slika sa širokim prostorima boja, ali slike mogu izgledati drugačije na starim uređajima sa starom verzijom preglednika. sRGB slike čuvaju se kao sRGB kako bi se izbjegle promjene boja.", "image_preview_description": "Slika srednje veličine s ogoljenim metapodacima, koristi se prilikom pregledavanja jednog sredstva i za strojno učenje", "image_preview_quality_description": "Kvaliteta pregleda od 1-100. Više je bolje, ali proizvodi veće datoteke i može smanjiti odziv aplikacije. Postavljanje niske vrijednosti može utjecati na kvalitetu strojnog učenja.", + "image_preview_title": "Postavke pregleda", "image_quality": "Kvaliteta", + "image_resolution": "Rezolucija", + "image_resolution_description": "Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odziv aplikacije.", "image_settings": "Postavke slike", "image_settings_description": "Upravljajte kvalitetom i rezolucijom generiranih slika", + "image_thumbnail_description": "Mala minijatura s ogoljenim metapodacima, koristi se pri gledanju grupa fotografija poput glavne vremenske trake", + "image_thumbnail_quality_description": "Kvaliteta sličica od 1-100. Više je bolje, ali proizvodi veće datoteke i može smanjiti odziv aplikacije.", + "image_thumbnail_title": "Postavke sličica", "job_concurrency": "{job} istovremenost", "job_created": "Zadatak je kreiran", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", @@ -203,10 +217,13 @@ "require_password_change_on_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", "reset_settings_to_default": "Vrati postavke na zadane", "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", + "scanning_library": "Skeniranje biblioteke", "search_jobs": "Traži zadatke…", "send_welcome_email": "Pošaljite email dobrodošlice", "server_external_domain_settings": "Vanjska domena", "server_external_domain_settings_description": "Domena za javno dijeljene linkove, uključujući http(s)://", + "server_public_users": "Javni korisnici", + "server_public_users_description": "Svi korisnici (ime i e-pošta) navedeni su prilikom dodavanja korisnika u dijeljene albume. Kada je onemogućeno, popis korisnika bit će dostupan samo korisnicima administratora.", "server_settings": "Postavke servera", "server_settings_description": "Upravljanje postavkama servera", "server_welcome_message": "Poruka dobrodošlice", @@ -404,6 +421,7 @@ "birthdate_saved": "Datum rođenja uspješno spremljen", "birthdate_set_description": "Datum rođenja se koristi za izračunavanje godina ove osobe u trenutku fotografije.", "blurred_background": "Zamućena pozadina", + "bugs_and_feature_requests": "Bugovi i zahtjevi za značajke", "build": "Sagradi (Build)", "build_image": "Sagradi (Build) Image", "bulk_delete_duplicates_confirmation": "Jeste li sigurni da želite skupno izbrisati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i trajno izbrisati sve druge duplikate. Ne možete poništiti ovu radnju!", @@ -449,6 +467,7 @@ "confirm": "Potvrdi", "confirm_admin_password": "Potvrdite lozinku administratora", "confirm_delete_shared_link": "Jeste li sigurni da želite izbrisati ovu zajedničku vezu?", + "confirm_keep_this_delete_others": "Sva druga sredstva u nizu bit će izbrisana osim ovog sredstva. Jeste li sigurni da želite nastaviti?", "confirm_password": "Potvrdite lozinku", "contain": "Sadrži", "context": "Kontekst", @@ -498,11 +517,13 @@ "delete_key": "Ključ za brisanje", "delete_library": "Izbriši knjižnicu", "delete_link": "Izbriši poveznicu", + "delete_others": "Izbriši druge", "delete_shared_link": "Izbriši dijeljenu poveznicu", "delete_tag": "Izbriši oznaku", "delete_tag_confirmation_prompt": "Jeste li sigurni da želite izbrisati oznaku {tagName}?", "delete_user": "Izbriši korisnika", "deleted_shared_link": "Izbrisana dijeljena poveznica", + "deletes_missing_assets": "Briše sredstva koja nedostaju s diska", "description": "Opis", "details": "Detalji", "direction": "Smjer", @@ -587,6 +608,7 @@ "failed_to_create_shared_link": "Stvaranje dijeljene veze nije uspjelo", "failed_to_edit_shared_link": "Nije uspjelo uređivanje dijeljene poveznice", "failed_to_get_people": "Dohvaćanje ljudi nije uspjelo", + "failed_to_keep_this_delete_others": "Zadržavanje ovog sredstva i brisanje ostalih sredstava nije uspjelo", "failed_to_load_asset": "Učitavanje sredstva nije uspjelo", "failed_to_load_assets": "Učitavanje sredstava nije uspjelo", "failed_to_load_people": "Učitavanje ljudi nije uspjelo", @@ -654,6 +676,7 @@ "unable_to_remove_album_users": "Nije moguće ukloniti korisnike iz albuma", "unable_to_remove_api_key": "Nije moguće ukloniti API ključ", "unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti sredstva iz dijeljene poveznice", + "unable_to_remove_deleted_assets": "Nije moguće ukloniti izvanmrežne datoteke", "unable_to_remove_library": "Nije moguće ukloniti biblioteku", "unable_to_remove_partner": "Nije moguće ukloniti partnera", "unable_to_remove_reaction": "Nije moguće ukloniti reakciju", @@ -769,6 +792,8 @@ "jobs": "Poslovi", "keep": "Zadrži", "keep_all": "Zadrži Sve", + "keep_this_delete_others": "Zadrži ovo, izbriši ostale", + "kept_this_deleted_others": "Zadržana je ova datoteka i izbrisano {count, plural, one {# datoteka} other {# datoteka}}", "keyboard_shortcuts": "Prečaci tipkovnice", "language": "Jezik", "language_setting_description": "Odaberite željeni jezik", @@ -801,6 +826,7 @@ "look": "Izgled", "loop_videos": "Ponavljajte videozapise", "loop_videos_description": "Omogućite automatsko ponavljanje videozapisa u pregledniku detalja.", + "main_branch_warning": "Koristite razvojnu verziju; strogo preporučamo korištenje izdane verzije!", "make": "Proizvođač", "manage_shared_links": "Upravljanje dijeljenim vezama", "manage_sharing_with_partners": "Upravljajte dijeljenjem s partnerima", @@ -870,6 +896,7 @@ "notifications": "Obavijesti", "notifications_setting_description": "Upravljanje obavijestima", "oauth": "OAuth", + "official_immich_resources": "Službeni Immich resursi", "offline": "Izvan mreže", "offline_paths": "Izvanmrežne putanje", "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", @@ -998,11 +1025,13 @@ "recent_searches": "Nedavne pretrage", "refresh": "Osvježi", "refresh_encoded_videos": "Osvježite kodirane videozapise", + "refresh_faces": "Osvježite lica", "refresh_metadata": "Osvježi metapodatke", "refresh_thumbnails": "Osvježi sličice", "refreshed": "Osvježeno", "refreshes_every_file": "Osvježava svaku datoteku", "refreshing_encoded_video": "Osvježavanje kodiranog videa", + "refreshing_faces": "Osvježavanje lica", "refreshing_metadata": "Osvježavanje metapodataka", "regenerating_thumbnails": "Obnavljanje sličica", "remove": "Ukloni", @@ -1010,7 +1039,7 @@ "remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?", "remove_assets_title": "Ukloniti datoteke?", "remove_custom_date_range": "Ukloni prilagođeni datumski raspon", - "remove_deleted_assets": "", + "remove_deleted_assets": "Ukloni izbrisana sredstva", "remove_from_album": "Ukloni iz albuma", "remove_from_favorites": "Ukloni iz favorita", "remove_from_shared_link": "Ukloni iz dijeljene poveznice", @@ -1068,13 +1097,15 @@ "search_people": "Traži ljude", "search_places": "Traži mjesta", "search_settings": "Postavke pretraživanja", - "search_state": "", + "search_state": "Država pretraživanja...", + "search_tags": "Traži oznake...", "search_timezone": "Pretraži vremenske zone", - "search_type": "", - "search_your_photos": "", - "searching_locales": "", - "second": "", - "select_album_cover": "", + "search_type": "Vrsta pretraživanja", + "search_your_photos": "Pretražite svoje fotografije", + "searching_locales": "Traženje lokaliteta...", + "second": "Drugi", + "see_all_people": "Vidi sve ljude", + "select_album_cover": "Odaberite omot albuma", "select_all": "Odaberi sve", "select_all_duplicates": "Odaberi sve duplikate", "select_avatar_color": "", @@ -1194,6 +1225,8 @@ "user": "", "user_id": "", "user_usage_detail": "", + "user_usage_stats": "Statistika korištenja računa", + "user_usage_stats_description": "Pregledajte statistiku korištenja računa", "username": "", "users": "", "utilities": "", diff --git a/i18n/hu.json b/i18n/hu.json index 9461923b5f..c0582b0e31 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -222,6 +222,8 @@ "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", + "server_public_users": "Nyilvános felhasználók", + "server_public_users_description": "Az összes felhasználó (név és email) ki van írva, amikor egy felhasználót adsz hozzá egy megosztott albumhoz. Amikor le van tiltva, a felhasználólista csak adminok számára lesz elérhető.", "server_settings": "Szerver Beállítások", "server_settings_description": "Szerver beállítások kezelése", "server_welcome_message": "Üdvözlő üzenet", @@ -465,6 +467,7 @@ "confirm": "Jóváhagy", "confirm_admin_password": "Admin Jelszó Újból", "confirm_delete_shared_link": "Biztosan törölni szeretnéd ezt a megosztott linket?", + "confirm_keep_this_delete_others": "Minden más elem a készletben törlésre kerül, kivéve ezt az elemet. Biztosan folytatni szeretnéd?", "confirm_password": "Jelszó megerősítése", "contain": "Belül", "context": "Kontextus", @@ -514,6 +517,7 @@ "delete_key": "Kulcs törlése", "delete_library": "Képtár Törlése", "delete_link": "Link törlése", + "delete_others": "Többi törlése", "delete_shared_link": "Megosztott link törlése", "delete_tag": "Címke törlése", "delete_tag_confirmation_prompt": "Biztosan törölni szeretnéd a(z) {tagName} címkét?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link módosítása sikertelen", "failed_to_get_people": "Személyek lekérdezése sikertelen", + "failed_to_keep_this_delete_others": "Nem sikerült megtartani ezt az elemet, és a többi elemet törölni", "failed_to_load_asset": "Elem betöltése sikertelen", "failed_to_load_assets": "Elemek betöltése sikertelen", "failed_to_load_people": "Személyek betöltése sikertelen", @@ -787,6 +792,8 @@ "jobs": "Feladatok", "keep": "Megtart", "keep_all": "Összeset Megtart", + "keep_this_delete_others": "Ennek a meghagyása, a többi törlése", + "kept_this_deleted_others": "Ennek az elemnek és a törölteknek meghagyása {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Billentyűparancsok", "language": "Nyelv", "language_setting_description": "Válaszd ki preferált nyelvet", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Egyesítve lesznek", "third_party_resources": "Harmadik Féltől Származó Források", "time_based_memories": "Emlékek idő alapján", + "timeline": "Idővonal", "timezone": "Időzóna", "to_archive": "Archiválás", "to_change_password": "Jelszó megváltoztatása", @@ -1227,6 +1235,7 @@ "to_trash": "Lomtárba helyezés", "toggle_settings": "Beállítások átállítása", "toggle_theme": "Sötét téma átváltása", + "total": "Összesen", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Vásárlás kezelése", "user_role_set": "{user} felhasználónak {role} jogkör biztosítása", "user_usage_detail": "Felhasználó használati adatai", + "user_usage_stats": "Fiók használati statisztikái", + "user_usage_stats_description": "Fiók használati statisztikáinak megtekintése", "username": "Felhasználónév", "users": "Felhasználók", "utilities": "Segédeszközök", @@ -1297,6 +1308,7 @@ "view_all_users": "Minden Felhasználó Megtekintése", "view_in_timeline": "Megtekintés az idővonalon", "view_links": "Linkek megtekintése", + "view_name": "Megtekintés", "view_next_asset": "Következő elem megtekintése", "view_previous_asset": "Előző elem megtekintése", "view_stack": "Csoport Megtekintése", diff --git a/i18n/id.json b/i18n/id.json index a3defe3842..8ae53e0464 100644 --- a/i18n/id.json +++ b/i18n/id.json @@ -222,6 +222,8 @@ "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", + "server_public_users": "Pengguna Publik", + "server_public_users_description": "Semua pengguna (nama dan email) didaftarkan ketika menambahkan pengguna ke album terbagi. Ketika dinonaktifkan, daftar pengguna hanya akan tersedia kepada pengguna admin.", "server_settings": "Pengaturan Server", "server_settings_description": "Kelola pengaturan server", "server_welcome_message": "Pesan selamat datang", @@ -465,6 +467,7 @@ "confirm": "Konfirmasi", "confirm_admin_password": "Konfirmasi Kata Sandi Admin", "confirm_delete_shared_link": "Apakah Anda yakin ingin menghapus tautan terbagi ini?", + "confirm_keep_this_delete_others": "Semua aset lain di dalam stack akan dihapus kecuali aset ini. Anda yakin untuk melanjutkan?", "confirm_password": "Konfirmasi kata sandi", "contain": "Berisi", "context": "Konteks", @@ -514,6 +517,7 @@ "delete_key": "Hapus kunci", "delete_library": "Hapus Pustaka", "delete_link": "Hapus tautan", + "delete_others": "Hapus lainnya", "delete_shared_link": "Hapus tautan terbagi", "delete_tag": "Hapus tag", "delete_tag_confirmation_prompt": "Apakah Anda yakin ingin menghapus label tag {tagName}?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Gagal membuat tautan terbagi", "failed_to_edit_shared_link": "Gagal menyunting tautan terbagi", "failed_to_get_people": "Gagal mendapatkan orang", + "failed_to_keep_this_delete_others": "Gagal mempertahankan aset ini dan hapus aset-aset lainnya", "failed_to_load_asset": "Gagal membuka aset", "failed_to_load_assets": "Gagal membuka aset-aset", "failed_to_load_people": "Gagal mengunggah orang", @@ -787,6 +792,8 @@ "jobs": "Tugas", "keep": "Simpan", "keep_all": "Simpan Semua", + "keep_this_delete_others": "Pertahankan ini, hapus lainnya", + "kept_this_deleted_others": "Aset ini dipertahankan dan {count, plural, one {# asset} other {# assets}} dihapus", "keyboard_shortcuts": "Pintasan papan ketik", "language": "Bahasa", "language_setting_description": "Pilih bahasa Anda yang disukai", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Mereka akan digabungkan bersama", "third_party_resources": "Sumber Daya Pihak Ketiga", "time_based_memories": "Kenangan berbasis waktu", + "timeline": "Lini masa", "timezone": "Zona waktu", "to_archive": "Arsipkan", "to_change_password": "Ubah kata sandi", @@ -1227,6 +1235,7 @@ "to_trash": "Sampah", "toggle_settings": "Saklar pengaturan", "toggle_theme": "Beralih tema gelap", + "total": "Jumlah", "total_usage": "Jumlah penggunaan", "trash": "Sampah", "trash_all": "Buang Semua", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Atur pembelian kamu", "user_role_set": "Tetapkan {user} sebagai {role}", "user_usage_detail": "Detail penggunaan pengguna", + "user_usage_stats": "Statistik penggunaan akun", + "user_usage_stats_description": "Tampilkan statistik penggunaan akun", "username": "Nama pengguna", "users": "Pengguna", "utilities": "Peralatan", @@ -1297,6 +1308,7 @@ "view_all_users": "Tampilkan semua pengguna", "view_in_timeline": "Lihat di timeline", "view_links": "Tampilkan tautan", + "view_name": "Tampilkan", "view_next_asset": "Tampilkan aset berikutnya", "view_previous_asset": "Tampilkan aset sebelumnya", "view_stack": "Tampilkan Tumpukan", diff --git a/i18n/it.json b/i18n/it.json index f3e85802fb..ebde6072b4 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -23,6 +23,7 @@ "add_to": "Aggiungi a...", "add_to_album": "Aggiungi all'album", "add_to_shared_album": "Aggiungi all'album condiviso", + "add_url": "Aggiungi URL", "added_to_archive": "Aggiunto all'archivio", "added_to_favorites": "Aggiunto ai preferiti", "added_to_favorites_count": "Aggiunti {count, number} ai preferiti", @@ -222,6 +223,8 @@ "send_welcome_email": "Invia email di benvenuto", "server_external_domain_settings": "Dominio esterno", "server_external_domain_settings_description": "Dominio per link condivisi pubblicamente, incluso http(s)://", + "server_public_users": "Utenti Pubblici", + "server_public_users_description": "Tutti gli utenti (nome ed e-mail) sono elencati quando si aggiunge un utente agli album condivisi. Quando disabilitato, l'elenco degli utenti sarà disponibile solo per gli utenti amministratori.", "server_settings": "Impostazioni Server", "server_settings_description": "Gestisci le impostazioni del server", "server_welcome_message": "Messaggio di benvenuto", @@ -239,7 +242,7 @@ "storage_template_migration_description": "Applica il {template} attuale agli asset caricati in precedenza", "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifiche retroattivamente esegui {job}.", "storage_template_migration_job": "Processo Migrazione Modello di Archiviazione", - "storage_template_more_details": "Per più informazioni riguardo a questa funzionalità, consulta il Modello Archiviazione e le sue conseguenze", + "storage_template_more_details": "Per maggiori informazioni riguardo a questa funzionalità, consulta il Modello Archiviazione e le sue conseguenze", "storage_template_onboarding_description": "Quando attivata, questa funzionalità organizzerà automaticamente i file utilizzando il modello di archiviazione definito dall'utente. Per ragioni di stabilità, questa funzionalità è disabilitata per impostazione predefinita. Per più informazioni, consulta la documentazione.", "storage_template_path_length": "Limite approssimativo lunghezza percorso: {length, number}/{limit, number}", "storage_template_settings": "Modello Archiviazione", @@ -247,6 +250,9 @@ "storage_template_user_label": "{label} è l'etichetta di archiviazione dell'utente", "system_settings": "Impostazioni di sistema", "tag_cleanup_job": "Pulisci Tag", + "template_email_preview": "Anteprima", + "template_email_settings": "Template Email", + "template_settings": "Templates Notifiche", "theme_custom_css_settings": "CSS Personalizzato", "theme_custom_css_settings_description": "I Cascading Style Sheets (CSS) permettono di personalizzare l'interfaccia di Immich.", "theme_settings": "Impostazioni Tema", @@ -465,6 +471,7 @@ "confirm": "Conferma", "confirm_admin_password": "Conferma password amministratore", "confirm_delete_shared_link": "Sei sicuro di voler eliminare questo link condiviso?", + "confirm_keep_this_delete_others": "Tutti gli altri asset nello stack saranno eliminati, eccetto questo asset. Vuoi continuare?", "confirm_password": "Conferma password", "contain": "Adatta", "context": "Contesto", @@ -514,6 +521,7 @@ "delete_key": "Elimina chiave", "delete_library": "Elimina Libreria", "delete_link": "Elimina link", + "delete_others": "Elimina altri", "delete_shared_link": "Elimina link condiviso", "delete_tag": "Elimina tag", "delete_tag_confirmation_prompt": "Sei sicuro di voler cancellare il tag {tagName}?", @@ -604,6 +612,7 @@ "failed_to_create_shared_link": "Creazione del link condivisibile non riuscita", "failed_to_edit_shared_link": "Errore durante la modifica del link condivisibile", "failed_to_get_people": "Impossibile ottenere le persone", + "failed_to_keep_this_delete_others": "Impossibile conservare questa risorsa ed eliminare le altre risorse", "failed_to_load_asset": "Errore durante il caricamento della risorsa", "failed_to_load_assets": "Errore durante il caricamento delle risorse", "failed_to_load_people": "Caricamento delle persone non riuscito", @@ -787,6 +796,7 @@ "jobs": "Processi", "keep": "Mantieni", "keep_all": "Tieni tutto", + "keep_this_delete_others": "Tieni questo, elimina gli altri", "keyboard_shortcuts": "Scorciatoie da tastiera", "language": "Lingua", "language_setting_description": "Seleziona la tua lingua predefinita", @@ -944,18 +954,18 @@ "permanently_delete": "Elimina definitivamente", "permanently_delete_assets_count": "Cancella definitivamente {count, plural, one {l'asset} other {gli assets}}", "permanently_delete_assets_prompt": "Sei sicuro di voler cancellare definitivamente {count, plural, one {questo asset?} other {# assets?}} Questa operazione {count, plural, one {lo cancellerà dal suo} other {li cancellerà dai loro}} album.", - "permanently_deleted_asset": "Elimina asset definitivamente", + "permanently_deleted_asset": "Asset eliminato definitivamente", "permanently_deleted_assets_count": "Cancellati {count, plural, one {# asset} other {# assets}} definitivamente", "person": "Persona", "person_hidden": "{name}{hidden, select, true { (nascosto)} other {}}", - "photo_shared_all_users": "Sembra che tu abbia condiviso le foto con tutti gli utenti (oppure non hai utenti con cui condividerle).", + "photo_shared_all_users": "Sembra che tu abbia condiviso le foto con tutti gli utenti, oppure che non ci siano utenti con i quali condividerle.", "photos": "Foto", "photos_and_videos": "Foto & Video", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Foto}}", "photos_from_previous_years": "Foto degli anni scorsi", "pick_a_location": "Scegli una posizione", "place": "Posizione", - "places": "Location", + "places": "Luoghi", "play": "Avvia", "play_memories": "Avvia ricordi", "play_motion_photo": "Avvia Foto in movimento", @@ -1276,6 +1286,8 @@ "user_purchase_settings_description": "Gestisci il tuo acquisto", "user_role_set": "Imposta {user} come {role}", "user_usage_detail": "Dettagli utilizzo utente", + "user_usage_stats": "Statistiche d'uso", + "user_usage_stats_description": "Consulta le statistiche d'uso dell'account", "username": "Nome utente", "users": "Utenti", "utilities": "Utilità", diff --git a/i18n/ms.json b/i18n/ms.json index 9494d485b5..0474caa542 100644 --- a/i18n/ms.json +++ b/i18n/ms.json @@ -1,5 +1,5 @@ { - "about": "Tentang", + "about": "Kemaskini", "account": "Akaun", "account_settings": "Tetapan Akaun", "acknowledge": "Akui", @@ -34,6 +34,11 @@ "authentication_settings_disable_all": "Adakah anda pasti mahu melumpuhkan semua kaedah log masuk? Log masuk akan dilumpuhkan sepenuhnya.", "authentication_settings_reenable": "Untuk menghidupkan semula, guna Arahan Pelayan.", "background_task_job": "Tugas Latar Belakang", + "backup_database": "Sandar pangkalan data", + "backup_database_enable_description": "Aktifkan sandaran pangkalan data", + "backup_keep_last_amount": "Jumlah sandaran sebelumnya yang hendak disimpan", + "backup_settings": "Tetapan Sandaran", + "backup_settings_description": "Urus tetapan sandaran pangkalan data", "check_all": "Tanda Semua", "cleared_jobs": "Kerja telah dibersihkan untuk: {job}", "config_set_by_file": "Konfigurasi kini ditetapkan oleh fail konfigurasi", @@ -43,6 +48,8 @@ "confirm_reprocess_all_faces": "Adakah anda pasti mahu memproses semula semua wajah? Ini juga akan membersihkan orang bernama.", "confirm_user_password_reset": "Adakah anda pasti mahu menetapkan semula kata laluan {user}?", "create_job": "Cipta tugas", + "cron_expression": "Ungkapan cron", + "cron_expression_presets": "Pratetap-pratetap ungkapan Cron", "disable_login": "Lumpuhkan fungsi log masuk", "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mengesan imej yang serupa. Bergantung pada Carian Pintar", "exclusion_pattern_description": "Corak pengecualian membolehkan anda mengabaikan fail dan folder semasa mengimbas pustaka anda. Ini berguna jika anda mempunyai folder yang mengandungi fail yang anda tidak mahu import, seperti fail RAW.", @@ -114,6 +121,19 @@ "machine_learning_max_recognition_distance_description": "Jarak maksimum antara dua muka untuk dianggap sebagai orang yang sama, antara 0-2. Menurunkan ini boleh menghalang pelabelan dua orang sebagai orang yang sama, manakala menaikkannya boleh menghalang pelabelan orang yang sama sebagai dua orang yang berbeza. Ambil perhatian bahawa adalah lebih mudah untuk menggabungkan dua orang daripada membelah satu orang kepada dua, jadi silap pada bahagian ambang yang lebih rendah apabila boleh.", "machine_learning_min_detection_score": "Skor pengesanan minimum", "machine_learning_min_detection_score_description": "Skor keyakinan minimum untuk wajah dikesan dari 0-1. Nilai yang lebih rendah akan mengesan lebih banyak muka tetapi mungkin menghasilkan positif palsu.", - "machine_learning_min_recognized_faces": "Minimum mengenali wajah" - } + "machine_learning_min_recognized_faces": "Minimum mengenali wajah", + "machine_learning_min_recognized_faces_description": "Bilangan minima wajah yang dikenali untuk seseorang dicipta. Peningkatan ini menjadikan Pengecaman Wajah lebih tepat atas kos meningkatkan peluang wajah tidak diberikan kepada seseorang.", + "machine_learning_settings": "Tetapan Pembelajaran Mesin", + "map_dark_style": "Tema gelap", + "map_enable_description": "Aktifkan ciri peta", + "map_gps_settings": "Tetapan Peta & GPS", + "map_light_style": "Tema terang", + "map_settings": "Peta", + "map_settings_description": "Urus tetapan peta", + "notification_email_from_address": "Dari alamat", + "notification_email_from_address_description": "Alamat e-mel penghantar, sebagai contoh: \"Immich Photo Server \"", + "notification_settings": "Tetapan Pemberitahuan" + }, + "user_usage_stats": "Statistik penggunaan akaun", + "user_usage_stats_description": "Papar statistik penggunaan akaun" } diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index 3f74b3a7d6..958d08e62f 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -1,5 +1,5 @@ { - "about": "Om", + "about": "Oppdater", "account": "Konto", "account_settings": "Konto Innstillinger", "acknowledge": "Bekreft", @@ -33,6 +33,11 @@ "authentication_settings_disable_all": "Er du sikker på at du ønsker å deaktivere alle innloggingsmetoder? Innlogging vil bli fullstendig deaktivert.", "authentication_settings_reenable": "For å aktivere på nytt, bruk en Server Command.", "background_task_job": "Bakgrunnsjobber", + "backup_database": "Backupdatabase", + "backup_database_enable_description": "Aktiver databasebackup", + "backup_keep_last_amount": "Antall backuper å beholde", + "backup_settings": "Backupinnstillinger", + "backup_settings_description": "Håndter innstillinger for databasebackup", "check_all": "Merk Alle", "cleared_jobs": "Ryddet opp jobber for: {job}", "config_set_by_file": "Konfigurasjonen er for øyeblikket satt av en konfigurasjonsfil", @@ -41,6 +46,7 @@ "confirm_email_below": "For å bekrefte, skriv inn \"{email}\" nedenfor", "confirm_reprocess_all_faces": "Er du sikker på at du vil behandle alle ansikter på nytt? Dette vil også fjerne navngitte personer.", "confirm_user_password_reset": "Er du sikker på at du vil tilbakestille passordet til {user}?", + "create_job": "Lag jobb", "disable_login": "Deaktiver innlogging", "duplicate_detection_job_description": "Kjør maskinlæring på filer for å oppdage lignende bilder. Krever bruk av Smart Search", "exclusion_pattern_description": "Ekskluderingsmønstre lar deg ignorere filer og mapper når du skanner biblioteket ditt. Dette er nyttig hvis du har mapper som inneholder filer du ikke vil importere, for eksempel RAW-filer.", @@ -52,12 +58,15 @@ "failed_job_command": "Kommandoen {command} feilet for jobben: {job}", "force_delete_user_warning": "ADVARSEL: Dette vil umiddelbart fjerne brukeren og alle eiendeler. Dette kan ikke angres, og filene kan ikke gjenopprettes.", "forcing_refresh_library_files": "Tvinger oppdatering av alle bibliotekfiler", + "image_format": "Format", "image_format_description": "WebP gir mindre filer enn JPEG, men er tregere å lage.", "image_prefer_embedded_preview": "Foretrekk innebygd forhåndsvisning", "image_prefer_embedded_preview_setting_description": "Bruk innebygd forhåndsvisning i RAW-bilder som inndata til bildebehandling når tilgjengelig. Dette kan gi mer nøyaktige farger for noen bilder, men kvaliteten er avhengig av kamera og bildet kan ha komprimeringsartefakter.", "image_prefer_wide_gamut": "Foretrekk bredt fargespekter", "image_prefer_wide_gamut_setting_description": "Bruk Display P3 for miniatyrbilder. Dette bevarer glød bedre i bilder med bredt fargerom, men det kan hende bilder ser annerledes ut på gamle enheter med en gammel nettleserversjon. sRBG bilder beholdes som sRGB for å unngå fargeforskyvninger.", + "image_preview_title": "Forhåndsvisningsinnstillinger", "image_quality": "Kvalitet", + "image_resolution": "Oppløsning", "image_settings": "Bildeinnstilliinger", "image_settings_description": "Administrer kvalitet og oppløsning på genererte bilder", "job_concurrency": "{job} samtidighet", diff --git a/i18n/nl.json b/i18n/nl.json index 3420c5d105..9a16efe6e9 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -222,11 +222,13 @@ "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", + "server_public_users": "Openbare gebruikerslijst", + "server_public_users_description": "Alle gebruikers (met naam en e-mailadres) worden weergegeven wanneer een gebruiker wordt toegevoegd aan gedeelde albums. Wanneer uitgeschakeld, is de gebruikerslijst alleen beschikbaar voor beheerders.", "server_settings": "Serverinstellingen", "server_settings_description": "Beheer serverinstellingen", "server_welcome_message": "Welkomstbericht", "server_welcome_message_description": "Een bericht dat op de inlogpagina wordt weergegeven.", - "sidecar_job": "Sidecar metadata", + "sidecar_job": "Sidecar metagegevens", "sidecar_job_description": "Zoek of synchroniseer sidecar metadata van het bestandssysteem", "slideshow_duration_description": "Aantal seconden dat iedere afbeelding wordt getoond", "smart_search_job_description": "Voer machine learning uit op assets om te gebruiken voor slim zoeken", @@ -302,7 +304,7 @@ "transcoding_preferred_hardware_device_description": "Geldt alleen voor VAAPI en QSV. Stelt de dri node in die wordt gebruikt voor hardwaretranscodering.", "transcoding_preset_preset": "Preset (-preset)", "transcoding_preset_preset_description": "Compressiesnelheid. Langzamere presets produceren kleinere bestanden en verhogen de kwaliteit bij het targeten van een bepaalde bitrate. VP9 negeert snelheden boven 'faster'.", - "transcoding_reference_frames": "Reference frames", + "transcoding_reference_frames": "Referentie frames", "transcoding_reference_frames_description": "Het aantal frames om naar te verwijzen bij het comprimeren van een bepaald frame. Hogere waarden verbeteren de compressie-efficiëntie, maar vertragen de codering. Bij 0 wordt deze waarde automatisch ingesteld.", "transcoding_required_description": "Alleen video's die geen geaccepteerd formaat hebben", "transcoding_settings": "Instellingen voor videotranscodering", @@ -317,7 +319,7 @@ "transcoding_tone_mapping_description": "Probeert het uiterlijk van HDR-video's te behouden wanneer ze worden geconverteerd naar SDR. Elk algoritme maakt verschillende afwegingen voor kleur, detail en helderheid. Hable behoudt detail, Mobius behoudt kleur en Reinhard behoudt helderheid.", "transcoding_transcode_policy": "Transcodeerbeleid", "transcoding_transcode_policy_description": "Beleid voor wanneer een video getranscodeerd moet worden. HDR-video's worden altijd getranscodeerd (behalve als transcodering is uitgeschakeld).", - "transcoding_two_pass_encoding": "Two-pass encoding", + "transcoding_two_pass_encoding": "Two-pass encodering", "transcoding_two_pass_encoding_setting_description": "Transcodeer in twee passes om beter gecodeerde video's te produceren. Wanneer de maximale bitrate is ingeschakeld (vereist om te werken met H.264 en HEVC), gebruikt deze modus een bitraterange op basis van de maximale bitrate en negeert CRF. Voor VP9 kan CRF worden gebruikt als de maximale bitrate is uitgeschakeld.", "transcoding_video_codec": "Video codec", "transcoding_video_codec_description": "VP9 heeft een hoge efficiëntie en webcompatibiliteit, maar duurt langer om te transcoderen. HEVC presteert vergelijkbaar, maar heeft een lagere webcompatibiliteit. H.264 is breed compatibel en snel om te transcoderen, maar produceert veel grotere bestanden. AV1 is de meest efficiënte codec, maar mist ondersteuning op oudere apparaten.", @@ -475,6 +477,7 @@ "confirm": "Bevestigen", "confirm_admin_password": "Bevestig beheerder wachtwoord", "confirm_delete_shared_link": "Weet je zeker dat je deze gedeelde link wilt verwijderen?", + "confirm_keep_this_delete_others": "Alle andere assets in de stack worden verwijderd, behalve deze. Weet je zeker dat je wilt doorgaan?", "confirm_password": "Bevestig wachtwoord", "contain": "Bevat", "context": "Context", @@ -524,6 +527,7 @@ "delete_key": "Verwijder sleutel", "delete_library": "Verwijder bibliotheek", "delete_link": "Verwijder link", + "delete_others": "Andere verwijderen", "delete_shared_link": "Verwijder gedeelde link", "delete_tag": "Tag verwijderen", "delete_tag_confirmation_prompt": "Weet je zeker dat je de tag {tagName} wilt verwijderen?", @@ -614,6 +618,7 @@ "failed_to_create_shared_link": "Fout bij maken van gedeelde link", "failed_to_edit_shared_link": "Fout bij bewerken van gedeelde link", "failed_to_get_people": "Fout bij ophalen van mensen", + "failed_to_keep_this_delete_others": "Het is niet gelukt om dit asset te behouden en de andere assets te verwijderen", "failed_to_load_asset": "Kan asset niet laden", "failed_to_load_assets": "Kan assets niet laden", "failed_to_load_people": "Kan mensen niet laden", @@ -662,7 +667,7 @@ "unable_to_empty_trash": "Kan prullenbak niet legen", "unable_to_enter_fullscreen": "Kan volledig scherm niet openen", "unable_to_exit_fullscreen": "Kan volledig scherm niet afsluiten", - "unable_to_get_comments_number": "Kan het aantal opmerkingen niet ophalen", + "unable_to_get_comments_number": "Niet mogelijk om het aantal opmerkingen op te halen", "unable_to_get_shared_link": "Kan gedeelde link niet ophalen", "unable_to_hide_person": "Kan persoon niet verbergen", "unable_to_link_motion_video": "Kan bewegende video niet verbinden", @@ -797,6 +802,8 @@ "jobs": "Taken", "keep": "Behouden", "keep_all": "Behoud alle", + "keep_this_delete_others": "Deze behouden, andere verwijderen", + "kept_this_deleted_others": "Deze asset behouden en {count, plural, one {# andere asset} other {# andere assets}} verwijderd", "keyboard_shortcuts": "Sneltoetsen", "language": "Taal", "language_setting_description": "Selecteer je voorkeurstaal", @@ -1228,6 +1235,7 @@ "they_will_be_merged_together": "Zij zullen worden samengevoegd", "third_party_resources": "Bronnen van derden", "time_based_memories": "Tijdgebaseerde herinneringen", + "timeline": "Tijdlijn", "timezone": "Tijdzone", "to_archive": "Archiveren", "to_change_password": "Wijzig wachtwoord", @@ -1237,6 +1245,7 @@ "to_trash": "Prullenbak", "toggle_settings": "Zichtbaarheid instellingen wisselen", "toggle_theme": "Donker thema toepassen", + "total": "Totaal", "total_usage": "Totaal gebruik", "trash": "Prullenbak", "trash_all": "Verplaats alle naar prullenbak", @@ -1286,6 +1295,8 @@ "user_purchase_settings_description": "Beheer je aankoop", "user_role_set": "{user} instellen als {role}", "user_usage_detail": "Gedetailleerd gebruik van gebruikers", + "user_usage_stats": "Statistieken van accountgebruik", + "user_usage_stats_description": "Bekijk statistieken van accountgebruik", "username": "Gebruikersnaam", "users": "Gebruikers", "utilities": "Gereedschap", @@ -1307,6 +1318,7 @@ "view_all_users": "Bekijk alle gebruikers", "view_in_timeline": "Bekijk in tijdlijn", "view_links": "Links bekijken", + "view_name": "Bekijken", "view_next_asset": "Bekijk volgende asset", "view_previous_asset": "Bekijk vorige asset", "view_stack": "Bekijk stapel", diff --git a/i18n/pl.json b/i18n/pl.json index 7e966c1cb5..8e2e52e03f 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -222,6 +222,8 @@ "send_welcome_email": "Wyślij powitalny e-mail", "server_external_domain_settings": "Domena zewnętrzna", "server_external_domain_settings_description": "Domena dla publicznie udostępnionych linków, wraz z http(s)://", + "server_public_users": "Użytkownicy publiczni", + "server_public_users_description": "Wszyscy użytkownicy (nazwa i adres e-mail) są wymienieni podczas dodawania użytkownika do udostępnionych albumów. Po wyłączeniu lista użytkowników będzie dostępna tylko dla administratorów.", "server_settings": "Ustawienia Serwera", "server_settings_description": "Zarządzaj ustawieniami serwera", "server_welcome_message": "Wiadomość powitalna", @@ -1223,6 +1225,7 @@ "they_will_be_merged_together": "Zostaną one ze sobą połączone", "third_party_resources": "Zasoby stron trzecich", "time_based_memories": "Wspomnienia oparte na czasie", + "timeline": "Oś czasu", "timezone": "Strefa czasowa", "to_archive": "Archiwum", "to_change_password": "Zmień hasło", @@ -1232,6 +1235,7 @@ "to_trash": "Kosz", "toggle_settings": "Przełącz ustawienia", "toggle_theme": "Przełącz ciemny motyw", + "total": "Całkowity", "total_usage": "Całkowite wykorzystanie", "trash": "Kosz", "trash_all": "Usuń wszystko", @@ -1281,6 +1285,8 @@ "user_purchase_settings_description": "Zarządzaj swoim zakupem", "user_role_set": "Ustaw {user} jako {role}", "user_usage_detail": "Szczegóły używania przez użytkownika", + "user_usage_stats": "Statystyki użytkowania konta", + "user_usage_stats_description": "Wyświetl statystyki użytkowania konta", "username": "Nazwa użytkownika", "users": "Użytkownicy", "utilities": "Narzędzia", @@ -1302,6 +1308,7 @@ "view_all_users": "Pokaż wszystkich użytkowników", "view_in_timeline": "Pokaż na osi czasu", "view_links": "Pokaż łącza", + "view_name": "Widok", "view_next_asset": "Wyświetl następny zasób", "view_previous_asset": "Wyświetl poprzedni zasób", "view_stack": "Zobacz Ułożenie", diff --git a/i18n/pt.json b/i18n/pt.json index 2677d0ffe3..3d9198644d 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -222,6 +222,8 @@ "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", + "server_public_users": "Utilizadores Públicos", + "server_public_users_description": "Todos os utilizadores (nome e e-mail) serão listados quando adicionar um utilizador a álbuns partilhados. Quando desativado, a lista de utilizadores só será visível a administradores.", "server_settings": "Definições do Servidor", "server_settings_description": "Gerir definições do servidor", "server_welcome_message": "Mensagem de boas-vindas", @@ -465,6 +467,7 @@ "confirm": "Confirmar", "confirm_admin_password": "Confirmar palavra-passe de administrador", "confirm_delete_shared_link": "Tem a certeza de que deseja eliminar este link partilhado?", + "confirm_keep_this_delete_others": "Todos os outros ficheiros na pilha serão eliminados, exceto este ficheiro. Tem a certeza de que deseja continuar?", "confirm_password": "Confirmar a palavra-passe", "contain": "Ajustar", "context": "Contexto", @@ -514,6 +517,7 @@ "delete_key": "Eliminar chave", "delete_library": "Eliminar Biblioteca", "delete_link": "Eliminar link", + "delete_others": "Excluir outros", "delete_shared_link": "Eliminar link de partilha", "delete_tag": "Eliminar etiqueta", "delete_tag_confirmation_prompt": "Tem a certeza de que pretende eliminar a etiqueta {tagName} ?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Não foi possível criar o link partilhado", "failed_to_edit_shared_link": "Não foi possível editar o link partilhado", "failed_to_get_people": "Não foi possível obter pessoas", + "failed_to_keep_this_delete_others": "Ocorreu um erro ao manter este ficheiro e eliminar os outros", "failed_to_load_asset": "Não foi possível ler o ficheiro", "failed_to_load_assets": "Não foi possível ler ficheiros", "failed_to_load_people": "Não foi possível carregar pessoas", @@ -787,6 +792,8 @@ "jobs": "Tarefas", "keep": "Manter", "keep_all": "Manter Todos", + "keep_this_delete_others": "Manter este ficheiro, eliminar os outros", + "kept_this_deleted_others": "Foi mantido ficheiro e {count, plural, one {eliminado # outro} other {eliminados # outros}}", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", "language_setting_description": "Selecione o seu Idioma preferido", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Eles serão unidos", "third_party_resources": "Recursos de terceiros", "time_based_memories": "Memórias baseadas no tempo", + "timeline": "Linha de tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", "to_change_password": "Alterar palavra-passe", @@ -1227,6 +1235,7 @@ "to_trash": "Reciclagem", "toggle_settings": "Alternar configurações", "toggle_theme": "Ativar modo escuro", + "total": "Total", "total_usage": "Total utilizado", "trash": "Reciclagem", "trash_all": "Mover todos para a reciclagem", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Gerir a sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de utilização do utilizador", + "user_usage_stats": "Estatísticas de utilização de conta", + "user_usage_stats_description": "Ver estatísticas de utilização de conta", "username": "Nome de utilizador", "users": "Utilizadores", "utilities": "Ferramentas", @@ -1297,6 +1308,7 @@ "view_all_users": "Ver todos os utilizadores", "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", + "view_name": "Ver", "view_next_asset": "Ver próximo ficheiro", "view_previous_asset": "Ver ficheiro anterior", "view_stack": "Ver pilha", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index ae129e603d..4c4608cf72 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -23,6 +23,7 @@ "add_to": "Adicionar a...", "add_to_album": "Adicionar ao álbum", "add_to_shared_album": "Adicionar ao álbum compartilhado", + "add_url": "Adicionar URL", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", @@ -44,7 +45,7 @@ "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", "confirm_delete_library_assets": "Você tem certeza que deseja excluir esta biblioteca? Isso excluirá {count, plural, one {# arquivo contido do Immich e não poderá ser desfeito. O arquivo permanecerá no disco} other {todos os # arquivos contidos do Immich e não poderá ser desfeito. Os arquivos permanecerão no disco}}.", - "confirm_email_below": "Para confirmar, digite o {email} abaixo", + "confirm_email_below": "Para confirmar, digite \"{email}\" abaixo", "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos os rostos? Isso também limpará as pessoas nomeadas.", "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", "create_job": "Criar tarefa", @@ -130,7 +131,7 @@ "machine_learning_smart_search_description": "Buscar imagens semanticamente usando embeddings CLIP", "machine_learning_smart_search_enabled": "Habilitar a Pesquisa Inteligente", "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "URL do servidor de inteligência artificial", + "machine_learning_url_description": "A URL do servidor de inteligência artificial. Se mais de uma URL for configurada, o servidor irá tentar uma de cada vez até que uma delas responda com sucesso, em ordem sequencial igual a configurada.", "manage_concurrency": "Gerenciar simultaneidade", "manage_log_settings": "Gerenciar configurações de registro", "map_dark_style": "Tema Escuro", @@ -159,7 +160,7 @@ "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", - "notification_email_from_address": "A partir do endereço", + "notification_email_from_address": "E-mail de origem", "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", @@ -170,8 +171,8 @@ "notification_email_setting_description": "Configurações para envio de notificações por e-mail", "notification_email_test_email": "Enviar e-mail de teste", "notification_email_test_email_failed": "Falha ao enviar e-mail de teste. Verifique seus valores", - "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", - "notification_email_username_description": "Nome de usuário a ser usado ao autenticar com o servidor de e-mail", + "notification_email_test_email_sent": "Um e-mail de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", + "notification_email_username_description": "Nome de usuário que será usado para autenticar com o servidor de e-mail", "notification_enable_email_notifications": "Habilitar notificações por e-mail", "notification_settings": "Configurações de notificação", "notification_settings_description": "Gerenciar configurações de notificação, incluindo e-mail", @@ -222,6 +223,8 @@ "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", + "server_public_users": "Usuários públicos", + "server_public_users_description": "Todos os usuários (nome e e-mail) serão exibidos na lista de adicionar usuários em álbuns compartilhados. Quando desativado, essa lista de usuários só será visível aos administradores.", "server_settings": "Configurações do servidor", "server_settings_description": "Gerenciar configurações do servidor", "server_welcome_message": "Mensagem de boas-vindas", @@ -247,6 +250,16 @@ "storage_template_user_label": "{label} é o Rótulo de Armazenamento do usuário", "system_settings": "Configurações do Sistema", "tag_cleanup_job": "Limpeza de tags", + "template_email_available_tags": "Você pode usar as seguintes variáveis no modelo: {tags}", + "template_email_if_empty": "Se o modelo estiver em branco, o modelo de e-mail padrão será usado.", + "template_email_invite_album": "Modelo do e-mail de convite para álbum", + "template_email_preview": "Pré visualização", + "template_email_settings": "Modelos de e-mail", + "template_email_settings_description": "Gerenciar modelos personalizados de e-mail de notificação", + "template_email_update_album": "Modelo do e-mail de atualização do álbum", + "template_email_welcome": "Modelo do e-mail de boas vindas", + "template_settings": "Modelos de notificação", + "template_settings_description": "Gerenciar modelos personalizados para notificações.", "theme_custom_css_settings": "CSS customizado", "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", "theme_settings": "Configurações de tema", @@ -465,6 +478,7 @@ "confirm": "Confirmar", "confirm_admin_password": "Confirmar senha de administrador", "confirm_delete_shared_link": "Tem certeza de que deseja excluir este link compartilhado?", + "confirm_keep_this_delete_others": "Todos os outros arquivos da pilha serão excluídos, exceto este arquivo. Tem certeza de que deseja continuar?", "confirm_password": "Confirme a senha", "contain": "Caber", "context": "Contexto", @@ -514,6 +528,7 @@ "delete_key": "Excluir chave", "delete_library": "Excluir biblioteca", "delete_link": "Excluir link", + "delete_others": "Excluir restante", "delete_shared_link": "Excluir link de compartilhamento", "delete_tag": "Remover tag", "delete_tag_confirmation_prompt": "Tem certeza que deseja excluir a tag {tagName} ?", @@ -604,6 +619,7 @@ "failed_to_create_shared_link": "Falha ao criar o link compartilhado", "failed_to_edit_shared_link": "Falha ao editar o link compartilhado", "failed_to_get_people": "Falha na obtenção de pessoas", + "failed_to_keep_this_delete_others": "Falha ao manter este arquivo e excluir os outros", "failed_to_load_asset": "Não foi possível carregar o ativo", "failed_to_load_assets": "Não foi possível carregar os ativos", "failed_to_load_people": "Falha ao carregar pessoas", @@ -718,6 +734,7 @@ "external": "Externo", "external_libraries": "Bibliotecas externas", "face_unassigned": "Sem nome", + "failed_to_load_assets": "Falha ao carregar arquivos", "favorite": "Favorito", "favorite_or_unfavorite_photo": "Marque ou desmarque a foto como favorita", "favorites": "Favoritos", @@ -787,6 +804,8 @@ "jobs": "Tarefas", "keep": "Manter", "keep_all": "Manter Todos", + "keep_this_delete_others": "Manter este, excluir o resto", + "kept_this_deleted_others": "Este foi mantido e {count, plural, one {# arquivo foi excluído} other {# arquivos foram excluídos}}", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", "language_setting_description": "Selecione seu Idioma preferido", @@ -1015,6 +1034,7 @@ "reassigned_assets_to_new_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a uma nova pessoa", "reassing_hint": "Atribuir arquivos selecionados a uma pessoa existente", "recent": "Recente", + "recent-albums": "Álbuns recentes", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", "refresh_encoded_videos": "Atualizar vídeos codificados", @@ -1036,6 +1056,7 @@ "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", "remove_from_shared_link": "Remover do link compartilhado", + "remove_url": "Remover URL", "remove_user": "Remover usuário", "removed_api_key": "Removido a Chave de API: {name}", "removed_from_archive": "Removido do arquivo", @@ -1218,6 +1239,7 @@ "they_will_be_merged_together": "Eles serão mesclados", "third_party_resources": "Recursos de terceiros", "time_based_memories": "Memórias baseada no tempo", + "timeline": "Linha do tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", "to_change_password": "Alterar senha", @@ -1227,6 +1249,7 @@ "to_trash": "Mover para a lixeira", "toggle_settings": "Alternar configurações", "toggle_theme": "Alternar tema escuro", + "total": "Total", "total_usage": "Utilização total", "trash": "Lixeira", "trash_all": "Mover todos para o lixo", @@ -1276,6 +1299,8 @@ "user_purchase_settings_description": "Gerenciar sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do usuário", + "user_usage_stats": "Estatísticas de utilização de conta", + "user_usage_stats_description": "Ver estatísticas de utilização de conta", "username": "Nome do usuário", "users": "Usuários", "utilities": "Utilitários", @@ -1297,6 +1322,7 @@ "view_all_users": "Ver todos usuários", "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", + "view_name": "Ver", "view_next_asset": "Ver próximo arquivo", "view_previous_asset": "Ver arquivo anterior", "view_stack": "Exibir Pilha", diff --git a/i18n/ru.json b/i18n/ru.json index d2326a275c..909cda6738 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -222,6 +222,8 @@ "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://", + "server_public_users": "Публичные пользователи", + "server_public_users_description": "Отображать всех пользователей (имена и email) для добавления в общие альбомы. Когда отключено, список пользователей будет доступен только администраторам.", "server_settings": "Настройки сервера", "server_settings_description": "Управление настройками сервера", "server_welcome_message": "Приветственное сообщение", @@ -465,7 +467,7 @@ "confirm": "Подтвердить", "confirm_admin_password": "Подтвердите пароль Администратора", "confirm_delete_shared_link": "Вы уверены, что хотите удалить эту публичную ссылку?", - "confirm_keep_this_delete_others": "Все остальные объекты в стеке будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", + "confirm_keep_this_delete_others": "Все остальные объекты в серии будут удалены, кроме этого объекта. Вы уверены, что хотите продолжить?", "confirm_password": "Подтвердите пароль", "contain": "Вместить", "context": "Контекст", @@ -1183,11 +1185,11 @@ "sort_recent": "Недавние фото", "sort_title": "Заголовок", "source": "Исходный код", - "stack": "В стопку", - "stack_duplicates": "Стек дубликатов", - "stack_select_one_photo": "Выберите одну главную фотографию для стека", - "stack_selected_photos": "Сложить выбранные фотографии в стопку", - "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в стек", + "stack": "Превратить в серию", + "stack_duplicates": "Превратить дубликаты в серию", + "stack_select_one_photo": "Выберите главную фотографию для серии", + "stack_selected_photos": "Объединить выбранные объекты в серию", + "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в серию", "stacktrace": "Трассировка стека", "start": "Старт", "start_date": "Дата начала", @@ -1223,6 +1225,7 @@ "they_will_be_merged_together": "Они будут объединены вместе", "third_party_resources": "Сторонние ресурсы", "time_based_memories": "Воспоминания, основанные на времени", + "timeline": "Временная шкала", "timezone": "Часовой пояс", "to_archive": "В архив", "to_change_password": "Изменить пароль", @@ -1232,6 +1235,7 @@ "to_trash": "Корзина", "toggle_settings": "Переключение настроек", "toggle_theme": "Переключение темы", + "total": "Всего", "total_usage": "Общее использование", "trash": "Корзина", "trash_all": "Удалить всё", @@ -1256,8 +1260,8 @@ "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", "unselect_all_duplicates": "Отменить выбор всех дубликатов", - "unstack": "Разобрать стек", - "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из стека", + "unstack": "Разгруппировать серию", + "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из серии", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_decription": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", "up_next": "Следующее", @@ -1281,6 +1285,8 @@ "user_purchase_settings_description": "Управление покупкой", "user_role_set": "Установить {user} в качестве {role}", "user_usage_detail": "Подробная информация об использовании пользователем", + "user_usage_stats": "Статистика использования аккаунта", + "user_usage_stats_description": "Посмотреть статистику использования аккаунта", "username": "Имя пользователя", "users": "Пользователи", "utilities": "Утилиты", @@ -1302,6 +1308,7 @@ "view_all_users": "Показать всех пользователей", "view_in_timeline": "Показать на временной шкале", "view_links": "Показать ссылки", + "view_name": "Посмотреть", "view_next_asset": "Показать следующий объект", "view_previous_asset": "Показать предыдущий объект", "view_stack": "Показать стек", diff --git a/i18n/sk.json b/i18n/sk.json index 22202359bc..fd58d50308 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -2,7 +2,7 @@ "about": "Obnoviť", "account": "Účet", "account_settings": "Nastavenia účtu", - "acknowledge": "Potvrdiť", + "acknowledge": "Rozumiem", "action": "Akcia", "actions": "Akcie", "active": "Aktívny", @@ -222,6 +222,8 @@ "send_welcome_email": "Odoslať uvítací e-mail", "server_external_domain_settings": "Externá doména", "server_external_domain_settings_description": "Verejná doména pre zdieľané odkazy, vrátane http(s)://", + "server_public_users": "Verejní užívatelia", + "server_public_users_description": "Všetci užívatelia (meno a email) sú uvedení pri pridávaní užívateľa do zdieľaných albumov. Ak je táto funkcia vypnutá, zoznam užívateľov bude dostupný iba správcom.", "server_settings": "Nastavenia servera", "server_settings_description": "Spravovať nastavenia servera", "server_welcome_message": "Uvítacia správa", @@ -233,7 +235,7 @@ "storage_template_date_time_description": "Časová pečiatka vytvorenia médií sa používa pre informácie o dátume a čase", "storage_template_date_time_sample": "Čas vzorky {date}", "storage_template_enable_description": "Povoliť nástroj šablóny úložiska", - "storage_template_hash_verification_enabled": "Hash overenie povolené", + "storage_template_hash_verification_enabled": "Overenie hash povolené", "storage_template_hash_verification_enabled_description": "Povolí overenie hash, nezakazujte to, pokiaľ si nie ste istí dôsledkami", "storage_template_migration": "Migrácia šablóny úložiska", "storage_template_migration_description": "Použite aktuálnu {template} na predtým nahrané médiá", @@ -392,35 +394,58 @@ "asset_adding_to_album": "Pridáva sa do albumu...", "asset_description_updated": "Popis média bol aktualizovaný", "asset_filename_is_offline": "Médium {filename} je offline", - "asset_offline": "", + "asset_has_unassigned_faces": "Položka má nepriradené tváre", + "asset_hashing": "Hašovanie...", + "asset_offline": "Médium je offline", + "asset_offline_description": "Toto externý obsah sa už nenachádza na disku. Požiadajte o pomoc svojho správcu Immich.", "asset_skipped": "Preskočené", "asset_skipped_in_trash": "V koši", "asset_uploaded": "Nahrané", "asset_uploading": "Nahráva sa...", "assets": "Položky", + "assets_added_count": "{count, plural, one {Pridaná # položka} few {Pridané # položky} other {Pridaných # položek}}", + "assets_added_to_album_count": "Do albumu {count, plural, one {bola pridaná # položka} few {boli pridané # položky} other {bolo pridaných # položiek}}", + "assets_added_to_name_count": "{count, plural, one {Pridaná # položka} few {Pridané # položky} other {Pridaných # položiek}} do {hasName, select, true {alba {name}} other {nového albumu}}", + "assets_count": "{count, plural, one {# položka} few {# položky} other {# položiek}}", + "assets_moved_to_trash_count": "Do koša {count, plural, one {bola presunutá # položka} few {boli presunuté # položky} other {bolo presunutých # položiek}}", + "assets_permanently_deleted_count": "Trvalo {count, plural, one {vymazaná # položka} few {vymazané # položky} other {vymazaných # položiek}}", + "assets_removed_count": "{count, plural, one {Odstránená # položka} few {Odstránené # položky} other {Odstránených # položiek}}", + "assets_restore_confirmation": "Naozaj chcete obnoviť všetky vyhodené položky? Túto akciu nie je možné vrátiť späť! Upozorňujeme, že týmto spôsobom nie je možné obnoviť žiadne offline položky.", + "assets_restored_count": "{count, plural, one {Obnovená # položka} few {Obnovené # položky} other {Obnovených # položiek}}", + "assets_trashed_count": "{count, plural, one {Odstránená # položka} few {Odstránené # položky} other {Odstránených # položiek}}", + "assets_were_part_of_album_count": "{count, plural, one {Položka bola} other {Položky boli}} súčasťou albumu", "authorized_devices": "Autorizované zariadenia", "back": "Späť", - "backward": "", + "back_close_deselect": "Späť, zavrieť alebo zrušiť výber", + "backward": "Spätne", "birthdate_saved": "Dátum narodenia bol úspešne uložený", - "blurred_background": "", + "birthdate_set_description": "Dátum narodenia sa používa na výpočet veku tejto osoby v čase fotografie.", + "blurred_background": "Rozmazané pozadie", + "bugs_and_feature_requests": "Chyby a požiadavky na funkcie", + "build": "Budovať", + "build_image": "Vytvoriť obrázok", + "bulk_delete_duplicates_confirmation": "Naozaj chcete hromadne odstrániť {count, plural, one {# duplikátnu položku} few {# duplikáte položky} other {# duplikátnych položiek}}? Týmto sa zachová najväčšia položka z každej skupiny a všetky ostatné duplikáty sa natrvalo odstránia. Túto akciu nie je možné vrátiť späť!", "buy": "Kúpiť Immich", "camera": "Fotoaparát", "camera_brand": "Výrobca fotoaparátu", "camera_model": "Model fotoaparátu", "cancel": "Zrušiť", "cancel_search": "Zrušiť vyhľadávanie", - "cannot_merge_people": "", + "cannot_merge_people": "Nie je možné zlúčiť ľudí", + "cannot_undo_this_action": "Túto akciu nemôžete vrátiť späť!", "cannot_update_the_description": "Popis nie je možné aktualizovať", "change_date": "Upraviť dátum", "change_expiration_time": "Zmeniť čas vypršania", "change_location": "Upraviť lokáciu", "change_name": "Upraviť meno", - "change_name_successfully": "", + "change_name_successfully": "Meno bolo zmenené", "change_password": "Zmeniť Heslo", - "change_your_password": "", - "changed_visibility_successfully": "", + "change_password_description": "Buď sa do systému prihlasujete prvýkrát, alebo bola podaná žiadosť o zmenu hesla. Nižšie zadajte nové heslo.", + "change_your_password": "Zmeňte si heslo", + "changed_visibility_successfully": "Viditeľnosť bola úspešne zmenená", "check_all": "Skontrolovať Všetko", "check_logs": "Skontrolovať logy", + "choose_matching_people_to_merge": "Vyberte rovnakých ľudí na zlúčenie", "city": "Mesto", "clear": "VYMAZAŤ", "clear_all": "Vymazať všetko", @@ -429,14 +454,18 @@ "clear_value": "Vymazať hodnotu", "clockwise": "V smere hodinových ručičiek", "close": "Zatvoriť", - "collapse_all": "", - "color_theme": "", + "collapse": "Zbaliť", + "collapse_all": "Zbaliť všetko", + "color": "Farba", + "color_theme": "Farba témy", "comment_deleted": "Komentár bol odstránený", "comment_options": "Možnosti komentára", + "comments_and_likes": "Komentáre a páči sa mi to", "comments_are_disabled": "Komentáre sú vypnuté", "confirm": "Potvrdiť", "confirm_admin_password": "Potvrdiť Administrátorské Heslo", "confirm_delete_shared_link": "Ste si istý, že chcete odstrániť tento zdieľaný odkaz?", + "confirm_keep_this_delete_others": "Všetky ostatné položky v zásobníku budú odstránené okrem tejto položky. Naozaj chcete pokračovať?", "confirm_password": "Potvrdiť heslo", "contain": "", "context": "Kontext", @@ -444,20 +473,21 @@ "copied_image_to_clipboard": "Obrázok skopírovaný do schránky.", "copied_to_clipboard": "Skopírované do schránky!", "copy_error": "Chyba pri kopírovaní", - "copy_file_path": "", + "copy_file_path": "Kopírovať cestu odkazu", "copy_image": "Skopírovať obrázok", "copy_link": "Skopírovať odkaz", "copy_link_to_clipboard": "Skopírovať do schránky", "copy_password": "Skopírovať heslo", "copy_to_clipboard": "Skopírovať do schránky", "country": "Štát", - "cover": "", - "covers": "", + "cover": "Titulka", + "covers": "Dlaždice", "create": "Vytvoriť", "create_album": "Vytvoriť album", "create_library": "Vytvoriť knižnicu", "create_link": "Vytvoriť odkaz", "create_link_to_share": "Vytvoriť odkaz na zdieľanie", + "create_link_to_share_description": "Umožniť každému kto má odkaz zobraziť vybrané fotografie", "create_new_person": "Vytvoriť novú osobu", "create_new_user": "Vytvorenie nového používateľa", "create_tag": "Vytvoriť značku", @@ -949,6 +979,8 @@ "user_id": "Používateľské ID", "user_role_set": "Nastav {user} ako {role}", "user_usage_detail": "", + "user_usage_stats": "Štatistiky využitia účtu", + "user_usage_stats_description": "Zobraziť štatistiky využitia účtu", "username": "Používateľské meno", "users": "Používatelia", "utilities": "Nástroje", diff --git a/i18n/sl.json b/i18n/sl.json index a61adbba93..3c3c67e131 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -202,7 +202,7 @@ "oauth_storage_quota_default_description": "Kvota v GiB, ki se uporabi, ko ni predložen noben zahtevek (vnesite 0 za neomejeno kvoto).", "offline_paths": "Poti brez povezave", "offline_paths_description": "Ti rezultati so morda posledica ročnega brisanja datotek, ki niso del zunanje knjižnice.", - "password_enable_description": "Prijava se z e-pošto in geslom", + "password_enable_description": "Prijava z e-pošto in geslom", "password_settings": "Prijava z geslom", "password_settings_description": "Upravljajte nastavitve prijave z geslom", "paths_validated_successfully": "Vse poti so bile uspešno potrjene", @@ -222,6 +222,8 @@ "send_welcome_email": "Pošlji pozdravno e-pošto", "server_external_domain_settings": "Zunanja domena", "server_external_domain_settings_description": "Domena za javne skupne povezave, vključno s http(s)://", + "server_public_users": "Javni uporabniki", + "server_public_users_description": "Vsi uporabniki (ime in e-pošta) so navedeni pri dodajanju uporabnika v albume v skupni rabi. Ko je onemogočen, bo seznam uporabnikov na voljo samo skrbniškim uporabnikom.", "server_settings": "Nastavitve strežnika", "server_settings_description": "Upravljanje nastavitev strežnika", "server_welcome_message": "Pozdravno sporočilo", @@ -400,7 +402,7 @@ "asset_skipped_in_trash": "V smetnjak", "asset_uploaded": "Naloženo", "asset_uploading": "Nalaganje ...", - "assets": "sredstva", + "assets": "Sredstva", "assets_added_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}}", "assets_added_to_album_count": "Dodano{count, plural, one {# sredstvo} other {# sredstev}} v album", "assets_added_to_name_count": "Dodano {count, plural, one {# sredstvo} other {# sredstev}} v {hasName, select, true {{name}} other {new album}}", @@ -465,6 +467,7 @@ "confirm": "Potrdi", "confirm_admin_password": "Potrdite skrbniško geslo", "confirm_delete_shared_link": "Ali ste prepričani, da želite izbrisati to skupno povezavo?", + "confirm_keep_this_delete_others": "Vsa druga sredstva v skladu bodo izbrisana, razen tega sredstva. Ste prepričani, da želite nadaljevati?", "confirm_password": "Potrdi geslo", "contain": "Vsebuje", "context": "Kontekst", @@ -514,6 +517,7 @@ "delete_key": "Izbriši ključ", "delete_library": "Izbriši knjižnico", "delete_link": "Izbriši povezavo", + "delete_others": "Izbriši ostale", "delete_shared_link": "Izbriši povezavo skupne rabe", "delete_tag": "Izbriši oznako", "delete_tag_confirmation_prompt": "Ali ste prepričani, da želite izbrisati oznako {tagName}?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Povezave v skupni rabi ni bilo mogoče ustvariti", "failed_to_edit_shared_link": "Povezave v skupni rabi ni bilo mogoče urediti", "failed_to_get_people": "Oseb ni bilo mogoče pridobiti", + "failed_to_keep_this_delete_others": "Tega sredstva ni bilo mogoče obdržati in izbrisati ostalih sredstev", "failed_to_load_asset": "Sredstva ni bilo mogoče naložiti", "failed_to_load_assets": "Sredstev ni bilo mogoče naložiti", "failed_to_load_people": "Oseb ni bilo mogoče naložiti", @@ -784,9 +789,11 @@ "invite_people": "Povabi ljudi", "invite_to_album": "Povabi v album", "items_count": "{count, plural, one {# predmet} other {# predmetov}}", - "jobs": "Dela", + "jobs": "Opravila", "keep": "Obdrži", "keep_all": "Obdrži vse", + "keep_this_delete_others": "Obdrži to, izbriši ostalo", + "kept_this_deleted_others": "Obdrži to sredstvo in izbriši {count, plural, one {# sredstvo} other {# sredstev}}", "keyboard_shortcuts": "Bližnjice na tipkovnici", "language": "Jezik", "language_setting_description": "Izberite želeni jezik", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Združeni bodo skupaj", "third_party_resources": "Viri tretjih oseb", "time_based_memories": "Časovni spomini", + "timeline": "Časovnica", "timezone": "Časovni pas", "to_archive": "Arhiv", "to_change_password": "Spremeni geslo", @@ -1227,6 +1235,7 @@ "to_trash": "Smetnjak", "toggle_settings": "Preklopi na nastavitve", "toggle_theme": "Preklopi na temno temo", + "total": "Skupno", "total_usage": "Skupna poraba", "trash": "Smetnjak", "trash_all": "Vse v smetnjak", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Upravljajte svoj nakup", "user_role_set": "Nastavi {user} kot {role}", "user_usage_detail": "Podrobnosti o uporabi uporabnika", + "user_usage_stats": "Statistika uporabe računa", + "user_usage_stats_description": "Oglejte si statistiko uporabe računa", "username": "Uporabniško ime", "users": "Uporabniki", "utilities": "Pripomočki", @@ -1297,6 +1308,7 @@ "view_all_users": "Ogled vseh uporabnikov", "view_in_timeline": "Ogled na časovnici", "view_links": "Ogled povezav", + "view_name": "Pogled", "view_next_asset": "Ogled naslednjega sredstva", "view_previous_asset": "Ogled prejšnjega sredstva", "view_stack": "Ogled sklada", @@ -1307,7 +1319,7 @@ "welcome": "Dobrodošli", "welcome_to_immich": "Dobrodošli v Immich", "year": "Leto", - "years_ago": "{years, plural, one {# leto} other {# let}} ago", + "years_ago": "{years, plural, one {# leto} other {# let}} nazaj", "yes": "Da", "you_dont_have_any_shared_links": "Nimate nobenih skupnih povezav", "zoom_image": "Povečava slike" diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 7fa6494079..6f60209ad4 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -23,6 +23,7 @@ "add_to": "Додај у...", "add_to_album": "Додај у албум", "add_to_shared_album": "Додај у дељен албум", + "add_url": "Додајте URL", "added_to_archive": "Додато у архиву", "added_to_favorites": "Додато у фаворите", "added_to_favorites_count": "Додато {count, number} у фаворите", @@ -222,6 +223,8 @@ "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", + "server_public_users": "Јавни корисници", + "server_public_users_description": "Сви корисници (име и адреса е-поште) су наведени приликом додавања корисника у дељене албуме. Када је онемогућена, листа корисника ће бити доступна само администраторима.", "server_settings": "Подешавања сервера", "server_settings_description": "Управљајте подешавањима сервера", "server_welcome_message": "Порука добродошлице", @@ -247,6 +250,11 @@ "storage_template_user_label": "{label} је ознака за складиштење корисника", "system_settings": "Подешавања система", "tag_cleanup_job": "Чишћење ознака (tags)", + "template_email_invite_album": "Шаблон албума позива", + "template_email_preview": "Преглед", + "template_email_settings": "Шаблони е-поште", + "template_email_settings_description": "Управљајте прилагођеним шаблонима обавештења путем е-поште", + "template_email_welcome": "Шаблон е-поште добродошлице", "theme_custom_css_settings": "Прилагођени CSS", "theme_custom_css_settings_description": "Каскадни листови стилова (CSS) омогућавају прилагођавање дизајна Immich-a.", "theme_settings": "Подешавање тема", @@ -1223,6 +1231,7 @@ "they_will_be_merged_together": "Они ће бити спојени заједно", "third_party_resources": "Ресурси трећих страна", "time_based_memories": "Сећања заснована на времену", + "timeline": "Временска линија", "timezone": "Временска зона", "to_archive": "Архивирај", "to_change_password": "Промени лозинку", @@ -1232,6 +1241,7 @@ "to_trash": "Смеће", "toggle_settings": "Намести подешавања", "toggle_theme": "Намести тамну тему", + "total": "Укупно", "total_usage": "Укупна употреба", "trash": "Отпад", "trash_all": "Баци све у отпад", @@ -1281,6 +1291,8 @@ "user_purchase_settings_description": "Управљајте куповином", "user_role_set": "Постави {user} као {role}", "user_usage_detail": "Детаљи коришћења корисника", + "user_usage_stats": "Статистика коришћења налога", + "user_usage_stats_description": "Погледајте статистику коришћења налога", "username": "Корисничко име", "users": "Корисници", "utilities": "Алати", @@ -1302,6 +1314,7 @@ "view_all_users": "Прикажи све кориснике", "view_in_timeline": "Прикажи у временској линији", "view_links": "Прикажи везе", + "view_name": "Погледати", "view_next_asset": "Погледајте следећу датотеку", "view_previous_asset": "Погледај претходну датотеку", "view_stack": "Прикажи гомилу", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index ea435c41e9..569c4efd50 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -222,6 +222,8 @@ "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", + "server_public_users": "Javni korisnici", + "server_public_users_description": "Svi korisnici (ime i adresa e-pošte) su navedeni prilikom dodavanja korisnika u deljene albume. Kada je onemogućena, lista korisnika će biti dostupna samo administratorima.", "server_settings": "Podešavanja servera", "server_settings_description": "Upravljajte podešavanjima servera", "server_welcome_message": "Poruka dobrodošlice", @@ -1223,6 +1225,7 @@ "they_will_be_merged_together": "Oni će biti spojeni zajedno", "third_party_resources": "Resursi trećih strana", "time_based_memories": "Sećanja zasnovana na vremenu", + "timeline": "Vremenska linija", "timezone": "Vremenska zona", "to_archive": "Arhiviraj", "to_change_password": "Promeni lozinku", @@ -1232,6 +1235,7 @@ "to_trash": "Smeće", "toggle_settings": "Namesti podešavanja", "toggle_theme": "Namesti tamnu temu", + "total": "Ukupno", "total_usage": "Ukupna upotreba", "trash": "Otpad", "trash_all": "Baci sve u otpad", @@ -1281,6 +1285,8 @@ "user_purchase_settings_description": "Upravljajte kupovinom", "user_role_set": "Postavi {user} kao {role}", "user_usage_detail": "Detalji korišćenja korisnika", + "user_usage_stats": "Statistika korišćenja naloga", + "user_usage_stats_description": "Pogledajte statistiku korišćenja naloga", "username": "Korisničko ime", "users": "Korisnici", "utilities": "Alati", @@ -1302,6 +1308,7 @@ "view_all_users": "Prikaži sve korisnike", "view_in_timeline": "Prikaži u vremenskoj liniji", "view_links": "Prikaži veze", + "view_name": "Pogledati", "view_next_asset": "Pogledajte sledeću datoteku", "view_previous_asset": "Pogledaj prethodnu datoteku", "view_stack": "Prikaži gomilu", diff --git a/i18n/tr.json b/i18n/tr.json index 5233d80d38..e9e16f6b60 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -222,6 +222,8 @@ "send_welcome_email": "Hoş geldin e-postası gönder", "server_external_domain_settings": "Dış domain", "server_external_domain_settings_description": "Paylaşılan fotoğraflar için domain, http(s):// dahil", + "server_public_users": "Harici Kullanıcılar", + "server_public_users_description": "Paylaşılan albümlere bir kullanıcı eklenirken tüm kullanıcılar (ad ve e-posta) listelenir. Devre dışı bırakıldığında, kullanıcı listesi yalnızca yönetici kullanıcılar tarafından kullanılabilir.", "server_settings": "Sunucu ayarları", "server_settings_description": "Sunucu ayarlarını yönet", "server_welcome_message": "Hoş geldin mesajı", @@ -465,6 +467,7 @@ "confirm": "Onayla", "confirm_admin_password": "Yönetici Şifresini Onayla", "confirm_delete_shared_link": "Bu paylaşılan bağlantıyı silmek istediğinizden emin misiniz?", + "confirm_keep_this_delete_others": "Yığındaki diğer tüm öğeler bu varlık haricinde silinecektir. Devam etmek istediğinizden emin misiniz?", "confirm_password": "Şifreyi onayla", "contain": "İçermek", "context": "Bağlam", @@ -514,6 +517,7 @@ "delete_key": "Anahtarı sil", "delete_library": "Kütüphaneyi sil", "delete_link": "Bağlantıyı sil", + "delete_others": "Diğerlerini sil", "delete_shared_link": "Paylaşılmış linki sil", "delete_tag": "Etiketi sil", "delete_tag_confirmation_prompt": "{tagName} etiketini silmek istediğinizden emin misiniz?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Paylaşılan bağlantı oluşturulamadı", "failed_to_edit_shared_link": "Paylaşılan bağlantı düzenlenemedi", "failed_to_get_people": "Kişiler alınamadı", + "failed_to_keep_this_delete_others": "Bu öğenin tutulması ve diğer öğenin silinmesi başarısız oldu", "failed_to_load_asset": "Varlık yüklenemedi", "failed_to_load_assets": "Varlıklar yüklenemedi", "failed_to_load_people": "Kişiler yüklenemedi", @@ -787,6 +792,8 @@ "jobs": "Görevler", "keep": "Koru", "keep_all": "Hepsini koru", + "keep_this_delete_others": "Bunu sakla, diğerlerini sil", + "kept_this_deleted_others": "Bu varlık tutuldu ve {count, plural, one {# varlık} other {# varlık}} silindi", "keyboard_shortcuts": "Klavye kısayolları", "language": "Dil", "language_setting_description": "Tercih ettiğiniz dili seçiniz", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Birlikte birleştirilecekler", "third_party_resources": "Üçüncü taraf kaynaklar", "time_based_memories": "Zaman bazlı anılar", + "timeline": "Zaman Çizelgesi", "timezone": "Zaman dilimi", "to_archive": "Arşivle", "to_change_password": "Şifreyi değiştir", @@ -1227,6 +1235,7 @@ "to_trash": "Çöpe taşı", "toggle_settings": "Ayarları değiştir", "toggle_theme": "Tema değiştir", + "total": "Toplam", "total_usage": "Toplam kullanım", "trash": "Çöp", "trash_all": "Hepsini sil", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Satın alma işlemlerini yönet", "user_role_set": "{user}, {role} olarak ayarlandı", "user_usage_detail": "Kullanıcı kullanım detayı", + "user_usage_stats": "Hesap kullanım istatistikleri", + "user_usage_stats_description": "hesap kullanım istatistiklerini göster", "username": "Kullanıcı adı", "users": "Kullanıcılar", "utilities": "Yardımcılar", @@ -1297,6 +1308,7 @@ "view_all_users": "Tüm kullanıcıları görüntüle", "view_in_timeline": "Zaman çizelgesinde görüntüle", "view_links": "Bağlantıları göster", + "view_name": "Göster", "view_next_asset": "Sonraki dosyayı görüntüle", "view_previous_asset": "Önceki dosyayı görüntüle", "view_stack": "Yığını görüntüle", diff --git a/i18n/uk.json b/i18n/uk.json index 57ac86afe5..1ab2812119 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -222,6 +222,8 @@ "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", + "server_public_users": "Публічні користувачі", + "server_public_users_description": "Усі користувачі (ім'я та електронна пошта) відображаються під час додавання користувача до спільних альбомів. Якщо вимкнено, список користувачів буде доступний лише адміністраторам.", "server_settings": "Налаштування сервера", "server_settings_description": "Керування налаштуваннями сервера", "server_welcome_message": "Вітальне повідомлення", @@ -419,7 +421,7 @@ "birthdate_saved": "Дата народження успішно збережена", "birthdate_set_description": "Дата народження використовується для обчислення віку цієї особи на момент фотографії.", "blurred_background": "Розмитий фон", - "bugs_and_feature_requests": "Помилки та запити на функції", + "bugs_and_feature_requests": "Помилки та Запити", "build": "Збірка", "build_image": "Створити зображення", "bulk_delete_duplicates_confirmation": "Ви впевнені, що хочете масово видалити {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}}? Це дія залишить найбільший ресурс у кожній групі і остаточно видалить всі інші дублікати. Цю дію неможливо скасувати!", @@ -465,6 +467,7 @@ "confirm": "Підтвердіть", "confirm_admin_password": "Підтвердити пароль адміністратора", "confirm_delete_shared_link": "Ви впевнені, що хочете видалити це спільне посилання?", + "confirm_keep_this_delete_others": "Усі інші ресурси в стеку буде видалено, окрім цього ресурсу. Ви впевнені, що хочете продовжити?", "confirm_password": "Підтвердити пароль", "contain": "Містити", "context": "Контекст", @@ -514,6 +517,7 @@ "delete_key": "Видалити ключ", "delete_library": "Видалити бібліотеку", "delete_link": "Видалити посилання", + "delete_others": "Видалити інші", "delete_shared_link": "Видалити спільне посилання", "delete_tag": "Видалити тег", "delete_tag_confirmation_prompt": "Ви впевнені, що хочете видалити тег {tagName}?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "Не вдалося створити спільне посилання", "failed_to_edit_shared_link": "Не вдалося відредагувати спільне посилання", "failed_to_get_people": "Не вдалося отримати інформацію про людей", + "failed_to_keep_this_delete_others": "Не вдалося зберегти цей ресурс і видалити інші ресурси", "failed_to_load_asset": "Не вдалося завантажити ресурс", "failed_to_load_assets": "Не вдалося завантажити ресурси", "failed_to_load_people": "Не вдалося завантажити людей", @@ -741,8 +746,8 @@ "go_to_search": "Перейти до пошуку", "group_albums_by": "Групувати альбоми за...", "group_no": "Без групування", - "group_owner": "Групування за власником", - "group_year": "Групувати за роками", + "group_owner": "За власником", + "group_year": "За роком", "has_quota": "Квота", "hi_user": "Привіт {name} ({email})", "hide_all_people": "Сховати всіх", @@ -787,6 +792,8 @@ "jobs": "Завдання", "keep": "Залишити", "keep_all": "Зберегти все", + "keep_this_delete_others": "Залишити цей ресурс, видалити інші", + "kept_this_deleted_others": "Збережено цей ресурс і видалено {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсу}}", "keyboard_shortcuts": "Сполучення клавіш", "language": "Мова", "language_setting_description": "Виберіть мову, якій ви надаєте перевагу", @@ -1177,7 +1184,7 @@ "sort_oldest": "Старі фото", "sort_recent": "Нещодавні", "sort_title": "Заголовок", - "source": "Джерело", + "source": "Вихідний код", "stack": "У стопку", "stack_duplicates": "Групувати дублікати", "stack_select_one_photo": "Вибрати одне основне фото для групи", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "Вони будуть об'єднані разом", "third_party_resources": "Ресурси третіх сторін", "time_based_memories": "Спогади, що базуються на часі", + "timeline": "Хронологія", "timezone": "Часовий пояс", "to_archive": "Архів", "to_change_password": "Змінити пароль", @@ -1227,6 +1235,7 @@ "to_trash": "Смітник", "toggle_settings": "Перемикання налаштувань", "toggle_theme": "Перемикання теми", + "total": "Усього", "total_usage": "Загальне використання", "trash": "Кошик", "trash_all": "Видалити все", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "Керувати вашою покупкою", "user_role_set": "Призначити {user} на роль {role}", "user_usage_detail": "Деталі використання користувача", + "user_usage_stats": "Статистика використання акаунта", + "user_usage_stats_description": "Переглянути статистику використання акаунта", "username": "Ім'я користувача", "users": "Користувачі", "utilities": "Утиліти", @@ -1297,6 +1308,7 @@ "view_all_users": "Переглянути всіх користувачів", "view_in_timeline": "Переглянути в хронології", "view_links": "Переглянути посилання", + "view_name": "Переглянути", "view_next_asset": "Переглянути наступний ресурс", "view_previous_asset": "Переглянути попередній ресурс", "view_stack": "Перегляд стеку", diff --git a/i18n/vi.json b/i18n/vi.json index 31f87d8cc6..9c30ea2935 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -48,6 +48,9 @@ "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", "create_job": "Tạo tác vụ", + "cron_expression": "Biểu thức Cron", + "cron_expression_description": "Thiết lập khoảng thời gian để quét bằng biểu thức cron. Tham khảo Crontab Guru để biết thêm thông tin.", + "cron_expression_presets": "Mẫu biểu thức Cron", "disable_login": "Vô hiệu hoá đăng nhập", "duplicate_detection_job_description": "Sử dụng Học máy để phát hiện các hình ảnh giống nhau. Dựa vào Tìm kiếm Thông Minh", "exclusion_pattern_description": "Quy tắc loại trừ cho bạn bỏ qua các tập tin và thư mục khi quét thư viện của bạn. Điều này hữu ích nếu bạn có các thư mục chứa tập tin bạn không muốn nhập, chẳng hạn như các tập tin RAW.", @@ -462,6 +465,7 @@ "confirm": "Xác nhận", "confirm_admin_password": "Xác nhận mật khẩu quản trị viên", "confirm_delete_shared_link": "Bạn có chắc chắn muốn xóa liên kết chia sẻ này không?", + "confirm_keep_this_delete_others": "Các hình còn lại trong stack này sẽ bị xoá ngoại trừ hình này. Bạn có chắc chắn tiếp tục không?", "confirm_password": "Xác nhận mật khẩu", "contain": "Chứa", "context": "Ngữ cảnh", @@ -511,6 +515,7 @@ "delete_key": "Xóa khóa", "delete_library": "Xóa Thư viện", "delete_link": "Xóa liên kết", + "delete_others": "Xoá các hình còn lại", "delete_shared_link": "Xóa liên kết chia sẻ", "delete_tag": "Xóa thẻ", "delete_tag_confirmation_prompt": "Bạn có chắc chắn muốn xóa thẻ {tagName} không?", @@ -601,6 +606,7 @@ "failed_to_create_shared_link": "Không thể tạo liên kết chia sẻ", "failed_to_edit_shared_link": "Không thể chỉnh sửa liên kết chia sẻ", "failed_to_get_people": "Không thể tải người", + "failed_to_keep_this_delete_others": "Có lỗi trong quá trình xoá các hình", "failed_to_load_asset": "Không thể tải ảnh", "failed_to_load_assets": "Không thể tải các ảnh", "failed_to_load_people": "Không thể tải người", @@ -784,6 +790,7 @@ "jobs": "Tác vụ", "keep": "Giữ", "keep_all": "Giữ tất cả", + "keep_this_delete_others": "Giữ tấm này và xoá tất cả còn lại", "keyboard_shortcuts": "Phím tắt", "language": "Ngôn ngữ", "language_setting_description": "Chọn ngôn ngữ ưa thích của bạn", @@ -1280,7 +1287,7 @@ "variables": "Các tham số", "version": "Phiên bản", "version_announcement_closing": "Bạn của bạn, Alex", - "version_announcement_message": "Chào bạn, có một phiên bản mới của ứng dụng. Vui lòng dành thời gian để xem ghi chú phát hành và đảm bảo rằng cấu hình docker-compose.yml.env của bạn được cập nhật để tránh bất kỳ cấu hình sai nào, đặc biệt nếu bạn sử dụng WatchTower hoặc bất kỳ cơ chế nào tự động cập nhật ứng dụng của bạn.", + "version_announcement_message": "Chào bạn! Một phiên bản mới của Immich đã phát hành. Vui lòng dành thời gian để xem danh sách thay đổi để đảm bảo cấu hình của bạn được cập nhật để tránh lỗi cấu hình sai, đặc biệt nếu bạn sử dụng WatchTower hoặc bất kỳ cơ chế tự động cập nhật Immich của bạn.", "version_history": "Lịch sử phiên bản", "version_history_item": "Đã cài đặt {version} vào {date}", "video": "Video", @@ -1302,7 +1309,7 @@ "warning": "Cảnh báo", "week": "Tuần", "welcome": "Chào mừng", - "welcome_to_immich": "Chào mừng đến với immich", + "welcome_to_immich": "Chào mừng đến với Immich", "year": "Năm", "years_ago": "{years, plural, one {# năm} other {# năm}} trước", "yes": "Có", diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index 792033cbfc..f3555148dd 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -222,6 +222,8 @@ "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開網址,,包含 http(s)://", + "server_public_users": "訪客使用者", + "server_public_users_description": "將使用者新增至共用相簿時,會列出所有使用者(姓名、email)。關閉時,使用者列表僅對管理者生效。", "server_settings": "伺服器", "server_settings_description": "管理伺服器設定", "server_welcome_message": "歡迎訊息", @@ -465,6 +467,7 @@ "confirm": "確認", "confirm_admin_password": "確認管理者密碼", "confirm_delete_shared_link": "確定刪除連結嗎?", + "confirm_keep_this_delete_others": "所有的其他堆疊項目將被刪除。確定繼續嗎?", "confirm_password": "確認密碼", "contain": "包含", "context": "情境", @@ -514,6 +517,7 @@ "delete_key": "刪除密鑰", "delete_library": "刪除圖庫", "delete_link": "刪除鏈結", + "delete_others": "刪除其他", "delete_shared_link": "刪除共享鏈結", "delete_tag": "刪除標記", "delete_tag_confirmation_prompt": "確定要刪除「{tagName}」(標記)嗎?", @@ -604,6 +608,7 @@ "failed_to_create_shared_link": "建立共享連結失敗", "failed_to_edit_shared_link": "編輯共享連結失敗", "failed_to_get_people": "無法獲取人物", + "failed_to_keep_this_delete_others": "無法保留此項目並刪除其他項目", "failed_to_load_asset": "檔案載入失敗", "failed_to_load_assets": "檔案載入失敗", "failed_to_load_people": "無法載入人物", @@ -787,6 +792,8 @@ "jobs": "作業", "keep": "保留", "keep_all": "全部保留", + "keep_this_delete_others": "保留這個,刪除其他", + "kept_this_deleted_others": "保留這個項目並刪除{count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "鍵盤快捷鍵", "language": "語言", "language_setting_description": "選擇您的首選語言", @@ -1218,6 +1225,7 @@ "they_will_be_merged_together": "它們將會被合併在一起", "third_party_resources": "第三方資源", "time_based_memories": "依時間回憶", + "timeline": "時間軸", "timezone": "時區", "to_archive": "封存", "to_change_password": "更改密碼", @@ -1227,6 +1235,7 @@ "to_trash": "垃圾桶", "toggle_settings": "切換設定", "toggle_theme": "切換深色主題", + "total": "統計", "total_usage": "總用量", "trash": "垃圾桶", "trash_all": "全部丟掉", @@ -1276,6 +1285,8 @@ "user_purchase_settings_description": "管理你的購買", "user_role_set": "設 {user} 爲{role}", "user_usage_detail": "使用者用量詳情", + "user_usage_stats": "帳號使用量統計", + "user_usage_stats_description": "查看帳號使用量", "username": "使用者名稱", "users": "使用者", "utilities": "工具", @@ -1297,6 +1308,7 @@ "view_all_users": "查看所有使用者", "view_in_timeline": "在時間軸中查看", "view_links": "檢視鏈結", + "view_name": "查看", "view_next_asset": "查看下一項", "view_previous_asset": "查看上一項", "view_stack": "查看堆疊", diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index 1e7872c2d6..7e82df1680 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -222,6 +222,8 @@ "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", + "server_public_users": "公共用户", + "server_public_users_description": "将用户添加到共享相册时,会列出所有用户(姓名和电子邮件)。禁用后,用户列表将仅对管理员用户可用。", "server_settings": "服务器设置", "server_settings_description": "管理服务器设置", "server_welcome_message": "欢迎消息", @@ -1223,6 +1225,7 @@ "they_will_be_merged_together": "项目将会合并到一起", "third_party_resources": "第三方资源", "time_based_memories": "基于时间的回忆", + "timeline": "时间线", "timezone": "时区", "to_archive": "归档", "to_change_password": "修改密码", @@ -1232,6 +1235,7 @@ "to_trash": "放入回收站", "toggle_settings": "切换设置", "toggle_theme": "切换深色主题", + "total": "总计", "total_usage": "总用量", "trash": "回收站", "trash_all": "全部删除", @@ -1281,6 +1285,8 @@ "user_purchase_settings_description": "管理购买订单", "user_role_set": "设置“{user}”为“{role}”", "user_usage_detail": "用户用量详情", + "user_usage_stats": "帐户使用统计", + "user_usage_stats_description": "查看帐户使用统计信息", "username": "用户名", "users": "用户", "utilities": "实用工具", @@ -1302,6 +1308,7 @@ "view_all_users": "查看全部用户", "view_in_timeline": "在时间轴中查看", "view_links": "查看链接", + "view_name": "查看", "view_next_asset": "查看下一项", "view_previous_asset": "查看上一项", "view_stack": "查看堆叠项目", From 5e662e4a937bddaddfe930b4f3b8078c8a82d2cf Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Dec 2024 10:26:48 -0600 Subject: [PATCH 514/599] chore(mobile): Translations update (#14493) chore(mobile): translation update --- mobile/assets/i18n/ar-JO.json | 33 +++++- mobile/assets/i18n/cs-CZ.json | 33 +++++- mobile/assets/i18n/da-DK.json | 35 +++++- mobile/assets/i18n/de-DE.json | 33 +++++- mobile/assets/i18n/el-GR.json | 33 +++++- mobile/assets/i18n/en-US.json | 70 ++++++------ mobile/assets/i18n/es-ES.json | 33 +++++- mobile/assets/i18n/es-MX.json | 33 +++++- mobile/assets/i18n/es-PE.json | 33 +++++- mobile/assets/i18n/es-US.json | 33 +++++- mobile/assets/i18n/fi-FI.json | 33 +++++- mobile/assets/i18n/fr-CA.json | 33 +++++- mobile/assets/i18n/fr-FR.json | 33 +++++- mobile/assets/i18n/he-IL.json | 33 +++++- mobile/assets/i18n/hi-IN.json | 195 ++++++++++++++++++-------------- mobile/assets/i18n/hu-HU.json | 33 +++++- mobile/assets/i18n/it-IT.json | 33 +++++- mobile/assets/i18n/ja-JP.json | 33 +++++- mobile/assets/i18n/ko-KR.json | 33 +++++- mobile/assets/i18n/lt-LT.json | 33 +++++- mobile/assets/i18n/lv-LV.json | 33 +++++- mobile/assets/i18n/mn-MN.json | 33 +++++- mobile/assets/i18n/nb-NO.json | 33 +++++- mobile/assets/i18n/nl-NL.json | 33 +++++- mobile/assets/i18n/pl-PL.json | 33 +++++- mobile/assets/i18n/pt-PT.json | 33 +++++- mobile/assets/i18n/ro-RO.json | 33 +++++- mobile/assets/i18n/ru-RU.json | 33 +++++- mobile/assets/i18n/sk-SK.json | 33 +++++- mobile/assets/i18n/sl-SI.json | 33 +++++- mobile/assets/i18n/sr-Cyrl.json | 33 +++++- mobile/assets/i18n/sr-Latn.json | 33 +++++- mobile/assets/i18n/sv-FI.json | 33 +++++- mobile/assets/i18n/sv-SE.json | 33 +++++- mobile/assets/i18n/th-TH.json | 33 +++++- mobile/assets/i18n/uk-UA.json | 33 +++++- mobile/assets/i18n/vi-VN.json | 39 ++++++- mobile/assets/i18n/zh-CN.json | 33 +++++- mobile/assets/i18n/zh-Hans.json | 33 +++++- mobile/assets/i18n/zh-TW.json | 33 +++++- 40 files changed, 1368 insertions(+), 159 deletions(-) diff --git a/mobile/assets/i18n/ar-JO.json b/mobile/assets/i18n/ar-JO.json index cbf05ca49c..accb707690 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "تحديث", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "تمت الاضافة{album}", "add_to_album_bottom_sheet_already_exists": "موجودة مسبقا {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "عارض الأصول", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "انقر للتضمين، وانقر نقرًا مزدوجًا للاستثناء", "backup_album_selection_page_assets_scatter": "يمكن أن تنتشر الأصول عبر ألبومات متعددة. وبالتالي، يمكن تضمين الألبومات أو استبعادها أثناء عملية النسخ الاحتياطي.", @@ -131,6 +137,7 @@ "backup_manual_success": "نجاح", "backup_manual_title": "حالة التحميل", "backup_options_page_title": "خيارات النسخ الاحتياطي", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "مسح ذاكرة التخزين المؤقت", "cache_settings_clear_cache_button_title": "يقوم بمسح ذاكرة التخزين المؤقت للتطبيق.سيؤثر هذا بشكل كبير على أداء التطبيق حتى إعادة بناء ذاكرة التخزين المؤقت.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "التحكم في سلوك التخزين المحلي", "cache_settings_tile_title": "التخزين المحلي", "cache_settings_title": "إعدادات التخزين المؤقت", + "cancel": "Cancel", "change_password_form_confirm_password": "تأكيد كلمة المرور", "change_password_form_description": "مرحبًا ،هذه هي المرة الأولى التي تقوم فيها بالتسجيل في النظام أو تم تقديم طلب لتغيير كلمة المرور الخاصة بك.الرجاء إدخال كلمة المرور الجديدة أدناه", "change_password_form_new_password": "كلمة المرور الجديدة", "change_password_form_password_mismatch": "كلمة المرور غير مطابقة", "change_password_form_reenter_new_password": "أعد إدخال كلمة مرور جديدة", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "أماكن", "curated_object_page_title": "أشياء", + "current_server_address": "Current server address", "daily_title_text_date": "E ، MMM DD", "daily_title_text_date_year": "E ، MMM DD ، yyyy", "date_format": "E ، Lll D ، Y • H: MM A", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "وحدة زمنية", "edit_image_title": "Edit", "edit_location_dialog_title": "موقع", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "اضف وصفا...", "exif_bottom_sheet_details": "تفاصيل", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "تمكين شبكة الصور التجريبية", "experimental_settings_subtitle": "استخدام على مسؤوليتك الخاصة!", "experimental_settings_title": "تجريبي", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "لم يتم العثور على الأصول المفضلة", "favorites_page_title": "المفضلة", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "تمكين ردود الفعل اللمسية", "haptic_feedback_title": "ردود فعل لمسية", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "أقدم صورة", "library_page_sort_most_recent_photo": "أحدث الصور", "library_page_sort_title": "عنوان الألبوم", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "اختر على الخريطة", "location_picker_latitude": "خط العرض", "location_picker_latitude_error": "أدخل خط عرض صالح", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "لا يمكن تعديل تاريخ الأصول (المواد) للقراءة فقط، سوف يتخطى", "multiselect_grid_edit_gps_err_read_only": "لا يمكن تعديل موقع الأصول (المواد) للقراءة فقط، سوف يتخطى", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "لا توجد أصول لعرضها", "no_name": "No name", "notification_permission_dialog_cancel": "يلغي", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "إذن محدود. للسماح بالنسخ الاحتياطي للتطبيق وإدارة مجموعة المعرض بالكامل، امنح أذونات الصور والفيديو في الإعدادات.", "permission_onboarding_request": "يتطلب التطبيق إذنًا لعرض الصور ومقاطع الفيديو الخاصة بك", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "التفضيلات", "profile_drawer_app_logs": "السجلات", "profile_drawer_client_out_of_date_major": "تطبيق الهاتف المحمول قديم.يرجى التحديث إلى أحدث إصدار رئيسي.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "نفايات", "recently_added": "Recently added", "recently_added_page_title": "أضيف مؤخرا", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "حدث خطأ", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "اقتراحات", "select_user_for_sharing_page_err_album": "فشل في إنشاء ألبوم", "select_user_for_sharing_page_share_suggestions": "اقتراحات", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "نسخة التطبيق", "server_info_box_latest_release": "احدث اصدار", "server_info_box_server_url": "عنوان URL الخادم", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "تحميل صورة معاينة", "setting_image_viewer_title": "الصور", "setting_languages_apply": "تغيير الإعدادات", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "اللغات", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -612,6 +639,8 @@ "upload_dialog_info": "هل تريد النسخ الاحتياطي للأصول (الأصول) المحددة إلى الخادم؟", "upload_dialog_ok": "رفع", "upload_dialog_title": "تحميل الأصول", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "يُقرّ", "version_announcement_overlay_release_notes": "ملاحظات الإصدار", "version_announcement_overlay_text_1": "مرحبًا يا صديقي ، هناك إصدار جديد", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "حذف من الكومه أو المجموعة", "viewer_stack_use_as_main_asset": "استخدم كأصل رئيسي", - "viewer_unstack": "فك الكومه" + "viewer_unstack": "فك الكومه", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 6e3462b26f..296c2ed5ce 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -7,6 +7,7 @@ "action_common_select": "Vybrat", "action_common_update": "Aktualizovat", "add_a_name": "Přidat název", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Přidáno do {album}", "add_to_album_bottom_sheet_already_exists": "Je již v {album}", "advanced_settings_log_level_title": "Úroveň protokolování: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} položek úspěšně obnoveno", "assets_trashed": "{} položek vyhozeno do koše", "assets_trashed_from_server": "{} položek vyhozeno do koše na Immich serveru", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Prohlížeč", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Alba v zařízení ({})", "backup_album_selection_page_albums_tap": "Klepnutím na položku ji zahrnete, opětovným klepnutím ji vyloučíte", "backup_album_selection_page_assets_scatter": "Položky mohou být roztroušeny ve více albech. To umožňuje zahrnout nebo vyloučit alba během procesu zálohování.", @@ -131,6 +137,7 @@ "backup_manual_success": "Úspěch", "backup_manual_title": "Stav nahrávání", "backup_options_page_title": "Nastavení záloh", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Náhledy stránek knihovny (položek {})", "cache_settings_clear_cache_button": "Vymazat vyrovnávací paměť", "cache_settings_clear_cache_button_title": "Vymaže vyrovnávací paměť aplikace. To výrazně ovlivní výkon aplikace, dokud se vyrovnávací paměť neobnoví.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Ovládání chování místního úložiště", "cache_settings_tile_title": "Místní úložiště", "cache_settings_title": "Nastavení vyrovnávací paměti", + "cancel": "Cancel", "change_password_form_confirm_password": "Potvrďte heslo", "change_password_form_description": "Dobrý den, {name}\n\nje to buď poprvé, co se přihlašujete do systému, nebo byl vytvořen požadavek na změnu hesla. Níže zadejte nové heslo.", "change_password_form_new_password": "Nové heslo", "change_password_form_password_mismatch": "Hesla se neshodují", "change_password_form_reenter_new_password": "Znovu zadejte nové heslo", + "check_corrupt_asset_backup": "Kontrola poškozených záloh položek", + "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.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Zadejte heslo", "client_cert_import": "Importovat", @@ -199,6 +210,7 @@ "crop": "Oříznout", "curated_location_page_title": "Místa", "curated_object_page_title": "Věci", + "current_server_address": "Current server address", "daily_title_text_date": "EEEE, d. MMMM", "daily_title_text_date_year": "EEEE, d. MMMM y", "date_format": "EEEE, d. MMMM y • H:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Upravit", "edit_location_dialog_title": "Poloha", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Chyba: {}", "exif_bottom_sheet_description": "Přidat popis...", "exif_bottom_sheet_details": "PODROBNOSTI", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií", "experimental_settings_subtitle": "Používejte na vlastní riziko!", "experimental_settings_title": "Experimentální", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Oblíbené", "favorites_page_no_favorites": "Nebyla nalezena žádná oblíbená média", "favorites_page_title": "Oblíbené", "filename_search": "Název nebo přípona souboru", "filter": "Filtr", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Povolit dotykovou zpětnou vazbu", "haptic_feedback_title": "Dotyková zpětná vazba", "header_settings_add_header_tip": "Přidat hlavičku", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Nejstarší fotografie", "library_page_sort_most_recent_photo": "Nejnovější fotografie", "library_page_sort_title": "Podle názvu alba", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Vyberte na mapě", "location_picker_latitude": "Zeměpisná šířka", "location_picker_latitude_error": "Zadejte platnou zeměpisnou šířku", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Nelze upravit datum položek pouze pro čtení, přeskakuji", "multiselect_grid_edit_gps_err_read_only": "Nelze upravit polohu položek pouze pro čtení, přeskakuji", "my_albums": "Moje alba", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Žádné položky k zobrazení", "no_name": "Bez jména", "notification_permission_dialog_cancel": "Zrušit", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Přístup omezen. Chcete-li používat Immich k zálohování a správě celé vaší kolekce galerií, povolte v nastavení přístup k fotkám a videím.", "permission_onboarding_request": "Immich potřebuje přístup k zobrazení vašich fotek a videí.", "places": "Místa", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Předvolby", "profile_drawer_app_logs": "Logy", "profile_drawer_client_out_of_date_major": "Mobilní aplikace je zastaralá. Aktualizujte ji na nejnovější hlavní verzi.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Vyhodit", "recently_added": "Nedávno přidané", "recently_added_page_title": "Nedávno přidané", + "save": "Save", "save_to_gallery": "Uložit do galerie", "scaffold_body_error_occurred": "Došlo k chybě", "search_albums": "Vyhledávejte alba", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Návrhy", "select_user_for_sharing_page_err_album": "Nepodařilo se vytvořit album", "select_user_for_sharing_page_share_suggestions": "Návrhy", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Verze aplikace", "server_info_box_latest_release": "Nejnovější verze", "server_info_box_server_url": "URL serveru", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Načíst náhled obrázku", "setting_image_viewer_title": "Obrázky", "setting_languages_apply": "Použít", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Jazyk", "setting_notifications_notify_failures_grace_period": "Oznámení o selhání zálohování na pozadí: {}", "setting_notifications_notify_hours": "{} hodin", @@ -612,6 +639,8 @@ "upload_dialog_info": "Chcete zálohovat vybrané položky na server?", "upload_dialog_ok": "Nahrát", "upload_dialog_title": "Nahrát položku", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Potvrdit", "version_announcement_overlay_release_notes": "poznámky k vydání", "version_announcement_overlay_text_1": "Ahoj, k dispozici je nová verze", @@ -621,5 +650,7 @@ "videos": "Videa", "viewer_remove_from_stack": "Odstranit ze zásobníku", "viewer_stack_use_as_main_asset": "Použít jako hlavní položku", - "viewer_unstack": "Rozbalit zásobník" + "viewer_unstack": "Rozbalit zásobník", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index e264187d5f..9b6c427628 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Opdater", "add_a_name": "Tilføj navn", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Tilføjet til {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", "advanced_settings_log_level_title": "Logniveau: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} element(er) blev gendannet succesfuldt", "assets_trashed": "{} element(er) blev smidt i papirkurven", "assets_trashed_from_server": "{} element(er) blev smidt i serverens papirkurv", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Billedviser", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albummer på enhed ({})", "backup_album_selection_page_albums_tap": "Tryk en gang for at inkludere, tryk to gange for at ekskludere", "backup_album_selection_page_assets_scatter": "Elementer kan være spredt på tværs af flere albummer. Albummer kan således inkluderes eller udelukkes under sikkerhedskopieringsprocessen.", @@ -131,6 +137,7 @@ "backup_manual_success": "Succes", "backup_manual_title": "Uploadstatus", "backup_options_page_title": "Backupindstillinger", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Biblioteksminiaturebilleder ({} elementer)", "cache_settings_clear_cache_button": "Fjern cache", "cache_settings_clear_cache_button_title": "Fjern appens cache. Dette vil i stor grad påvirke appens ydeevne indtil cachen er genopbygget.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kontroller den lokale lagerplads", "cache_settings_tile_title": "Lokal lagerplads", "cache_settings_title": "Cache-indstillinger", + "cancel": "Cancel", "change_password_form_confirm_password": "Bekræft kodeord", "change_password_form_description": "Hej {name},\n\nDette er enten første gang du logger ind eller også er der lavet en anmodning om at ændre dit kodeord. Indtast venligst et nyt kodeord nedenfor.", "change_password_form_new_password": "Nyt kodeord", "change_password_form_password_mismatch": "Kodeord er ikke ens", "change_password_form_reenter_new_password": "Gentag nyt kodeord", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Beskær", "curated_location_page_title": "Steder", "curated_object_page_title": "Ting", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E d. LLL y • hh:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Tidszone", "edit_image_title": "Rediger", "edit_location_dialog_title": "Placering", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Fejl: {}", "exif_bottom_sheet_description": "Tilføj beskrivelse...", "exif_bottom_sheet_details": "DETALJER", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Aktiver eksperimentelt fotogitter", "experimental_settings_subtitle": "Brug på eget ansvar!", "experimental_settings_title": "Eksperimentelle", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoritter", "favorites_page_no_favorites": "Ingen favoritter blev fundet", "favorites_page_title": "Favoritter", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Slå haptisk feedback til", "haptic_feedback_title": "Haptisk feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Ældste billede", "library_page_sort_most_recent_photo": "Seneste billede", "library_page_sort_title": "Albumtitel", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Vælg på kort", "location_picker_latitude": "Breddegrad", "location_picker_latitude_error": "Indtast en gyldig breddegrad", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Kan ikke redigere datoen på kun læselige elementer. Springer over", "multiselect_grid_edit_gps_err_read_only": "Kan ikke redigere lokation af kun læselige elementer. Springer over", "my_albums": "Mine albummer", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Ingen elementer at vise", "no_name": "Intet navn", "notification_permission_dialog_cancel": "Annuller", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Tilladelse begrænset. For at lade Immich lave sikkerhedskopi og styre hele dit galleri, skal der gives tilladelse til billeder og videoer i indstillinger.", "permission_onboarding_request": "Immich kræver tilliadelse til at se dine billeder og videoer.", "places": "Placeringer", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Præferencer", "profile_drawer_app_logs": "Log", "profile_drawer_client_out_of_date_major": "Mobilapp er forældet. Opdater venligst til den nyeste større version", @@ -412,9 +436,10 @@ "profile_drawer_trash": "Papirkurv", "recently_added": "Senest tilføjet", "recently_added_page_title": "Nyligt tilføjet", + "save": "Save", "save_to_gallery": "Gem til galleri", "scaffold_body_error_occurred": "Der opstod en fejl", - "search_albums": "Søb albummer", + "search_albums": "Søg i albummer", "search_bar_hint": "Søg i dine billeder", "search_filter_apply": "Tilføj filter", "search_filter_camera": "Kamera", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Anbefalinger", "select_user_for_sharing_page_err_album": "Fejlede i at oprette et nyt album", "select_user_for_sharing_page_share_suggestions": "Anbefalinger", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Applikationsversion", "server_info_box_latest_release": "Seneste version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Indlæs forhåndsvisning af billedet", "setting_image_viewer_title": "Images", "setting_languages_apply": "Anvend", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Sprog", "setting_notifications_notify_failures_grace_period": "Giv besked om fejl med sikkerhedskopiering i baggrunden: {}", "setting_notifications_notify_hours": "{} timer", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vil du sikkerhedskopiere de(t) valgte element(er) til serveren?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload element", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Accepter", "version_announcement_overlay_release_notes": "udgivelsesnoterne", "version_announcement_overlay_text_1": "Hej ven, der er en ny version af", @@ -621,5 +650,7 @@ "videos": "Videoer", "viewer_remove_from_stack": "Fjern fra stak", "viewer_stack_use_as_main_asset": "Brug som hovedelement", - "viewer_unstack": "Fjern fra stak" + "viewer_unstack": "Fjern fra stak", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index d844879739..60b2ea31be 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -7,6 +7,7 @@ "action_common_select": "Auswählen ", "action_common_update": "Aktualisieren", "add_a_name": "Einen Namen hinzufügen", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Zu {album} hinzugefügt", "add_to_album_bottom_sheet_already_exists": "Bereits in {album}", "advanced_settings_log_level_title": "Log-Level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} Datei/en erfolgreich wiederhergestellt", "assets_trashed": "{} Datei/en gelöscht", "assets_trashed_from_server": "{} Datei/en vom Immich-Server gelöscht", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Fotoanzeige", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})", "backup_album_selection_page_albums_tap": "Einmalig das Album antippen um es zu sichern, doppelt antippen um es nicht mehr zu sichern.", "backup_album_selection_page_assets_scatter": "Elemente (Fotos / Videos) können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden.", @@ -131,6 +137,7 @@ "backup_manual_success": "Erfolgreich", "backup_manual_title": "Sicherungsstatus", "backup_options_page_title": "Sicherungsoptionen", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Vorschaubilder der Bibliothek ({} Elemente)", "cache_settings_clear_cache_button": "Zwischenspeicher löschen", "cache_settings_clear_cache_button_title": "Löscht den Zwischenspeicher der App. Dies wird die Leistungsfähigkeit der App deutlich einschränken, bis der Zwischenspeicher wieder aufgebaut wurde.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Lokalen Speicher verwalten", "cache_settings_tile_title": "Lokaler Speicher", "cache_settings_title": "Zwischenspeicher Einstellungen", + "cancel": "Cancel", "change_password_form_confirm_password": "Passwort bestätigen", "change_password_form_description": "Hallo {name}\n\nDas ist entweder das erste Mal dass du dich einloggst oder es wurde eine Anfrage zur Änderung deines Passwortes gestellt. Bitte gib das neue Passwort ein.", "change_password_form_new_password": "Neues Passwort", "change_password_form_password_mismatch": "Passwörter stimmen nicht überein", "change_password_form_reenter_new_password": "Passwort erneut eingeben", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Passwort eingeben", "client_cert_import": "Importieren", @@ -199,6 +210,7 @@ "crop": "Zuschneiden", "curated_location_page_title": "Orte", "curated_object_page_title": "Dinge", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E d. LLL y • hh:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Zeitzone", "edit_image_title": "Bearbeiten", "edit_location_dialog_title": "Ort bearbeiten", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Fehler: {}", "exif_bottom_sheet_description": "Beschreibung hinzufügen...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Experimentelles Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", "experimental_settings_title": "Experimentell", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoriten", "favorites_page_no_favorites": "Keine favorisierten Inhalte gefunden", "favorites_page_title": "Favoriten", "filename_search": "Dateiname oder Dateityp", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Haptisches Feedback aktivieren", "haptic_feedback_title": "Haptisches Feedback", "header_settings_add_header_tip": "Header hinzufügen", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Ältestes Foto", "library_page_sort_most_recent_photo": "Neuestes Foto", "library_page_sort_title": "Titel des Albums", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Auf der Karte auswählen", "location_picker_latitude": "Breitengrad", "location_picker_latitude_error": "Gültigen Breitengrad eingeben", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Das Datum und die Uhrzeit von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", "multiselect_grid_edit_gps_err_read_only": "Der Aufnahmeort von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", "my_albums": "Meine Alben", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Keine Vorschau vorhanden", "no_name": "Kein Name", "notification_permission_dialog_cancel": "Abbrechen", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Berechtigungen unzureichend. Um Immich das Sichern von ganzen Sammlungen zu ermöglichen, muss der Zugriff auf alle Fotos und Videos in den Einstellungen erlaubt werden.", "permission_onboarding_request": "Immich benötigt Berechtigung um auf deine Fotos und Videos zuzugreifen.", "places": "Orte", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Voreinstellungen", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile-App ist veraltet. Bitte aktualisiere auf die neueste Major-Version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papierkorb", "recently_added": "Kürzlich hinzugefügt", "recently_added_page_title": "Zuletzt hinzugefügt", + "save": "Save", "save_to_gallery": "In Galerie speichern", "scaffold_body_error_occurred": "Ein Fehler ist aufgetreten", "search_albums": "nach Album suchen", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Vorschläge", "select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden", "select_user_for_sharing_page_share_suggestions": "Empfehlungen", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App-Version", "server_info_box_latest_release": "Neueste Version", "server_info_box_server_url": "Server-URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Vorschaubild laden", "setting_image_viewer_title": "Bilder", "setting_languages_apply": "Anwenden", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Sprachen", "setting_notifications_notify_failures_grace_period": "Benachrichtigung bei Fehler/n in der Hintergrundsicherung: {}", "setting_notifications_notify_hours": "{} Stunden", @@ -612,6 +639,8 @@ "upload_dialog_info": "Willst du die ausgewählten Elemente auf dem Server sichern?", "upload_dialog_ok": "Hochladen", "upload_dialog_title": "Element hochladen", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Ich habe verstanden", "version_announcement_overlay_release_notes": "Änderungsprotokoll", "version_announcement_overlay_text_1": "Hallo mein Freund! Es gibt eine neue Version von", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Aus Stapel entfernen", "viewer_stack_use_as_main_asset": "An Stapelanfang", - "viewer_unstack": "Stapel aufheben" + "viewer_unstack": "Stapel aufheben", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index d99f6d6ff0..f4b2facb24 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -7,6 +7,7 @@ "action_common_select": "Επιλογή", "action_common_update": "Ενημέρωση", "add_a_name": "Πρόσθεση ονόματος", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Προστέθηκε στο {album}", "add_to_album_bottom_sheet_already_exists": "Ήδη στο {album}", "advanced_settings_log_level_title": "Επίπεδο καταγραφής: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} στοιχεία αποκαταστάθηκαν με επιτυχία", "assets_trashed": "{} στοιχεία μεταφέρθηκαν στον κάδο απορριμμάτων", "assets_trashed_from_server": "{} στοιχεία μεταφέρθηκαν στον κάδο απορριμμάτων από τον διακομιστή Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Προβολή Στοιχείων", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Άλμπουμ στη συσκευή ({})", "backup_album_selection_page_albums_tap": "Πάτημα για συμπερίληψη, διπλό πάτημα για εξαίρεση", "backup_album_selection_page_assets_scatter": "Τα στοιχεία μπορεί να διασκορπιστούν σε πολλά άλμπουμ. Έτσι, τα άλμπουμ μπορούν να περιληφθούν ή να εξαιρεθούν κατά τη διαδικασία δημιουργίας αντιγράφων ασφαλείας.", @@ -131,6 +137,7 @@ "backup_manual_success": "Επιτυχία", "backup_manual_title": "Κατάσταση μεταφόρτωσης", "backup_options_page_title": "Επιλογές αντιγράφων ασφαλείας", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Μικρογραφίες σελίδας βιβλιοθήκης ({} στοιχεία)", "cache_settings_clear_cache_button": "Εκκαθάριση προσωρινής μνήμης", "cache_settings_clear_cache_button_title": "Καθαρίζει τη προσωρινή μνήμη της εφαρμογής. Αυτό θα επηρεάσει σημαντικά την απόδοση της εφαρμογής μέχρι να αναδημιουργηθεί η προσωρινή μνήμη.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Χειριστείτε τη συμπεριφορά της τοπικής αποθήκευσης", "cache_settings_tile_title": "Τοπική Αποθήκευση", "cache_settings_title": "Ρυθμίσεις Προσωρινής Μνήμης", + "cancel": "Cancel", "change_password_form_confirm_password": "Επιβεβαίωση Κωδικού", "change_password_form_description": "Γεια σας {name},\n\nΕίτε είναι η πρώτη φορά που συνδέεστε στο σύστημα είτε έχει γίνει αίτηση για αλλαγή του κωδικού σας. Παρακαλώ εισάγετε τον νέο κωδικό.", "change_password_form_new_password": "Νέος Κωδικός", "change_password_form_password_mismatch": "Οι κωδικοί δεν ταιριάζουν", "change_password_form_reenter_new_password": "Επανεισαγωγή Νέου Κωδικού", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "ΟΚ", "client_cert_enter_password": "Εισαγάγετε κωδικό πρόσβασης", "client_cert_import": "Εισαγωγή", @@ -199,6 +210,7 @@ "crop": "Αποκοπή", "curated_location_page_title": "Τοποθεσίες", "curated_object_page_title": "Πράγματα", + "current_server_address": "Current server address", "daily_title_text_date": "Ε, MMM dd", "daily_title_text_date_year": "Ε, MMM dd, yyyy", "date_format": "Ε, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Ζώνη ώρας", "edit_image_title": "Επεξεργασία", "edit_location_dialog_title": "Τοποθεσία", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Σφάλμα: {}", "exif_bottom_sheet_description": "Προσθήκη Περιγραφής...", "exif_bottom_sheet_details": "ΛΕΠΤΟΜΕΡΕΙΕΣ", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Ενεργοποίηση πειραματικού πλέγματος φωτογραφιών", "experimental_settings_subtitle": "Χρησιμοποιείτε με δική σας ευθύνη!", "experimental_settings_title": "Πειραματικό", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Αγαπημένα", "favorites_page_no_favorites": "Δεν βρέθηκαν αγαπημένα στοιχεία", "favorites_page_title": "Αγαπημένα", "filename_search": "Όνομα αρχείου ή επέκταση", "filter": "Φίλτρο", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Ενεργοποίηση απτικής ανάδρασης", "haptic_feedback_title": "Απτική Ανάδραση", "header_settings_add_header_tip": "Προσθήκη Κεφαλίδας", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Πιο παλιά φωτογραφία", "library_page_sort_most_recent_photo": "Πιο πρόσφατη φωτογραφία", "library_page_sort_title": "Τίτλος άλμπουμ", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Επιλέξτε στο χάρτη", "location_picker_latitude": "Γεωγραφικό πλάτος", "location_picker_latitude_error": "Εισαγάγετε ένα έγκυρο γεωγραφικό πλάτος", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Δεν είναι δυνατή η επεξεργασία της ημερομηνίας των στοιχείων μόνο για ανάγνωση, παραλείπεται", "multiselect_grid_edit_gps_err_read_only": "Δεν είναι δυνατή η επεξεργασία της τοποθεσίας των στοιχείων μόνο για ανάγνωση, παραλείπεται", "my_albums": "Τα άλμπουμ μου", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Δεν υπάρχουν στοιχεία προς εμφάνιση", "no_name": "Κανένα όνομα", "notification_permission_dialog_cancel": "Ακύρωση", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Περιορισμένη άδεια. Για να επιτρέψετε στο Immich να δημιουργεί αντίγραφα ασφαλείας και να διαχειρίζεται ολόκληρη τη συλλογή σας, παραχωρήστε άδειες φωτογραφιών και βίντεο στις Ρυθμίσεις.", "permission_onboarding_request": "Το Immich απαιτεί άδεια πρόσβασεις στις φωτογραφίες και τα βίντεό σας.", "places": "Μέρη", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Προτιμήσεις", "profile_drawer_app_logs": "Καταγραφές", "profile_drawer_client_out_of_date_major": "Παρακαλώ ενημερώστε την εφαρμογή στην πιο πρόσφατη κύρια έκδοση.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Σκουπίδια", "recently_added": "Προστέθηκαν πρόσφατα", "recently_added_page_title": "Προστέθηκαν Πρόσφατα", + "save": "Save", "save_to_gallery": "Αποθήκευση στη συλλογή", "scaffold_body_error_occurred": "Παρουσιάστηκε σφάλμα", "search_albums": "Αναζήτηση άλμπουμ", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Προτάσεις", "select_user_for_sharing_page_err_album": "Αποτυχία δημιουργίας άλπουμ", "select_user_for_sharing_page_share_suggestions": "Προτάσεις", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Έκδοση εφαρμογής", "server_info_box_latest_release": "Τελευταία Έκδοση", "server_info_box_server_url": "URL διακομιστή", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Φόρτωση εικόνας προεπισκόπησης", "setting_image_viewer_title": "Εικόνες", "setting_languages_apply": "Εφαρμογή", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Γλώσσες", "setting_notifications_notify_failures_grace_period": "Ειδοποίηση αποτυχιών δημιουργίας αντιγράφων ασφαλείας στο παρασκήνιο: {}", "setting_notifications_notify_hours": "{} ώρες", @@ -612,6 +639,8 @@ "upload_dialog_info": "Θέλετε να αντιγράψετε (κάνετε backup) τα επιλεγμένo(α) στοιχείο(α) στο διακομιστή;", "upload_dialog_ok": "Ανέβασμα", "upload_dialog_title": "Ανέβασμα στοιχείου", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Κατάλαβα", "version_announcement_overlay_release_notes": "σημειώσεις έκδοσης", "version_announcement_overlay_text_1": "Γειά σας, υπάρχει μια νέα έκδοση του", @@ -621,5 +650,7 @@ "videos": "Βίντεο", "viewer_remove_from_stack": "Κατάργηση από τη Στοίβα", "viewer_stack_use_as_main_asset": "Χρήση ως Κύριο Στοιχείο", - "viewer_unstack": "Αποστοίβαξε" + "viewer_unstack": "Αποστοίβαξε", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 121e3e4982..6fb2ed4ff5 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,35 +1,4 @@ { - "location_permission": "Location permission", - "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", - "background_location_permission": "Background location permission", - "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", - "current_server_address": "Current server address", - "grant_permission": "Grant permission", - "automatic_endpoint_switching_title": "Automatic URL switching", - "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", - "local_network": "Local network", - "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", - "external_network": "External network", - "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", - "networking_settings": "Networking", - "networking_subtitle": "Manage the server endpoint settings", - "cancel": "Cancel", - "save": "Save", - "wifi_name": "WiFi Name", - "enter_wifi_name": "Enter WiFi name", - "your_wifi_name": "Your WiFi name", - "server_endpoint": "Server Endpoint", - "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", - "use_current_connection": "use current connection", - "add_endpoint": "Add endpoint", - "validate_endpoint_error": "Please enter a valid URL", - "advanced_settings_tile_subtitle": "Manage advanced settings", - "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", - "backup_setting_subtitle": "Manage background and foreground upload settings", - "setting_languages_subtitle": "Change the app's language", - "setting_notifications_subtitle": "Manage your notification settings", - "preferences_settings_subtitle": "Manage the app's preferences", - "asset_list_settings_subtitle": "Manage the look of the timeline", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", @@ -38,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -47,6 +17,7 @@ "advanced_settings_proxy_headers_title": "Proxy Headers", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", @@ -86,6 +57,7 @@ "asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month_day": "Month + day", "asset_list_layout_sub_title": "Layout", + "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", "assets_deleted_permanently": "{} asset(s) deleted permanently", @@ -94,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Gallery Viewer", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", + "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -160,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -178,14 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", "check_corrupt_asset_backup": "Check for corrupt asset backups", - "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -231,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -267,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -278,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -328,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -397,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -430,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -444,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -501,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -512,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -521,6 +516,7 @@ "setting_notifications_notify_seconds": "{} seconds", "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_title": "Notifications", "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", @@ -643,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", @@ -652,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" +} \ No newline at end of file diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 5f7f8a12b1..43f03f3ace 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -7,6 +7,7 @@ "action_common_select": "Seleccionar", "action_common_update": "Actualizar", "add_a_name": "Añadir nombre", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Nivel de registro: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} elemento(s) restaurado(s) exitosamente", "assets_trashed": "{} elemento(s) eliminado(s)", "assets_trashed_from_server": "{} elemento(s) movido a la papelera en Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Visor de Archivos", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, doble toque para excluir", "backup_album_selection_page_assets_scatter": "Los elementos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", @@ -131,6 +137,7 @@ "backup_manual_success": "Éxito", "backup_manual_title": "Estado de la subida", "backup_options_page_title": "Opciones de Copia de Seguridad", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} elementos)", "cache_settings_clear_cache_button": "Borrar caché", "cache_settings_clear_cache_button_title": "Borra la caché de la aplicación. Esto afectará significativamente el rendimiento de la aplicación hasta que se reconstruya la caché.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controla el comportamiento del almacenamiento local", "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmar Contraseña", "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Introduzca contraseña", "client_cert_import": "Importar", @@ -199,6 +210,7 @@ "crop": "Recortar", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", + "current_server_address": "Current server address", "daily_title_text_date": "E dd, MMM", "daily_title_text_date_year": "E dd de MMM, yyyy", "date_format": "E d, LLL y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Zona horaria", "edit_image_title": "Editar", "edit_location_dialog_title": "Ubicación", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Agregar Descripción...", "exif_bottom_sheet_details": "DETALLES", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoritos", "favorites_page_no_favorites": "No se encontraron elementos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "Nombre o extensión", "filter": "Filtrar", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Activar respuesta háptica", "haptic_feedback_title": "Respuesta Háptica", "header_settings_add_header_tip": "Añadir cabecera", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Foto más antigua", "library_page_sort_most_recent_photo": "Foto más reciente", "library_page_sort_title": "Título del álbum", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Elegir en el mapa", "location_picker_latitude": "Latitud", "location_picker_latitude_error": "Introduce una latitud válida", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "No se puede cambiar la fecha del archivo(s) de solo lectura, omitiendo", "multiselect_grid_edit_gps_err_read_only": "No se puede cambiar la localización de archivos de solo lectura. Saltando.", "my_albums": "Mis álbumes", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No hay elementos a mostrar", "no_name": "Sin nombre", "notification_permission_dialog_cancel": "Cancelar", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", "places": "Lugares", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferencias", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "La app está desactualizada. Por favor actualiza a la última versión principal.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papelera", "recently_added": "Añadidos recientemente", "recently_added_page_title": "Recién Agregadas", + "save": "Save", "save_to_gallery": "Guardado en la galería", "scaffold_body_error_occurred": "Ha ocurrido un error", "search_albums": "Buscar álbum", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugerencias", "select_user_for_sharing_page_err_album": "Fallo al crear el álbum", "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versión de la Aplicación", "server_info_box_latest_release": "Ultima versión", "server_info_box_server_url": "URL del servidor", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Cargar imagen de previsualización", "setting_image_viewer_title": "Imágenes", "setting_languages_apply": "Aplicar", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Idiomas", "setting_notifications_notify_failures_grace_period": "Notificar fallos de copia de seguridad en segundo plano: {}", "setting_notifications_notify_hours": "{} horas", @@ -612,6 +639,8 @@ "upload_dialog_info": "Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?", "upload_dialog_ok": "Subir", "upload_dialog_title": "Subir elementos", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Aceptar", "version_announcement_overlay_release_notes": "notas de versión", "version_announcement_overlay_text_1": "Hola amigo, hay una nueva versión de", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", - "viewer_unstack": "Desapilar" + "viewer_unstack": "Desapilar", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 8c07c6a362..ea4e794677 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", "backup_album_selection_page_assets_scatter": "Los archivos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", @@ -131,6 +137,7 @@ "backup_manual_success": "Éxito", "backup_manual_title": "Estado de la subida", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} archivos)", "cache_settings_clear_cache_button": "Borrar caché", "cache_settings_clear_cache_button_title": "Borra la caché de la aplicación. Esto afectará significativamente el rendimiento de la aplicación hasta que se reconstruya la caché.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controla el comportamiento del almacenamiento local", "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmar Contraseña", "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd de MMM de yyyy", "date_format": "E d, LLL y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Agregar Descripción...", "exif_bottom_sheet_details": "DETALLES", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Foto más reciente", "library_page_sort_title": "Título del álbum", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papelera", "recently_added": "Recently added", "recently_added_page_title": "Recién Agregadas", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugerencias", "select_user_for_sharing_page_err_album": "Error al crear álbum", "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versión de la Aplicación", "server_info_box_latest_release": "Ultima versión", "server_info_box_server_url": "URL del servidor", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Cargar imagen de previsualización", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notificar fallos de copia de seguridad en segundo plano: {}", "setting_notifications_notify_hours": "{} horas", @@ -612,6 +639,8 @@ "upload_dialog_info": "Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?", "upload_dialog_ok": "Subir", "upload_dialog_title": "Subir elementos", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Aceptar", "version_announcement_overlay_release_notes": "notas de la versión", "version_announcement_overlay_text_1": "Hola, amigo, hay una nueva versión de", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", - "viewer_unstack": "Desapilar" + "viewer_unstack": "Desapilar", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 23eaa437ff..a88d95837d 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", "backup_album_selection_page_assets_scatter": "Los archivos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", @@ -131,6 +137,7 @@ "backup_manual_success": "Éxito", "backup_manual_title": "Estado de la subida", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} archivos)", "cache_settings_clear_cache_button": "Borrar caché", "cache_settings_clear_cache_button_title": "Borra la caché de la aplicación. Esto afectará significativamente el rendimiento de la aplicación hasta que se reconstruya la caché.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controla el comportamiento del almacenamiento local", "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmar Contraseña", "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd de MMM de yyyy", "date_format": "E d, LLL y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Agregar Descripción...", "exif_bottom_sheet_details": "DETALLES", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Foto más reciente", "library_page_sort_title": "Título del álbum", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papelera", "recently_added": "Recently added", "recently_added_page_title": "Recién Agregadas", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugerencias", "select_user_for_sharing_page_err_album": "Error al crear álbum", "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versión de la Aplicación", "server_info_box_latest_release": "Ultima versión", "server_info_box_server_url": "URL del servidor", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Cargar imagen de previsualización", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notificar fallos de copia de seguridad en segundo plano: {}", "setting_notifications_notify_hours": "{} horas", @@ -612,6 +639,8 @@ "upload_dialog_info": "Quieres hacer una copia de seguridad al servidor de los elementos seleccionados?", "upload_dialog_ok": "Subir", "upload_dialog_title": "Subir elementos", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Aceptar", "version_announcement_overlay_release_notes": "notas de la versión", "version_announcement_overlay_text_1": "Hola, amigo, hay una nueva versión de", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", - "viewer_unstack": "Desapilar" + "viewer_unstack": "Desapilar", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index 61c84d0054..3e4ed7bde2 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", "backup_album_selection_page_assets_scatter": "Los archivos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", @@ -131,6 +137,7 @@ "backup_manual_success": "Exitoso", "backup_manual_title": "Estado de subida", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} recursos)", "cache_settings_clear_cache_button": "Borrar caché", "cache_settings_clear_cache_button_title": "Borra la caché de la aplicación. Esto afectará significativamente el rendimiento de la aplicación hasta que se reconstruya la caché.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controla el comportamiento del almacenamiento local", "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmar Contraseña", "change_password_form_description": "Hola {name},\n\nÉsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd de MMM, yyyy", "date_format": "E d, LLL y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Agregar Descripción...", "exif_bottom_sheet_details": "DETALLES", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "¡Úsalo bajo tu propio riesgo!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Foto más reciente", "library_page_sort_title": "Título del álbum", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papelera", "recently_added": "Recently added", "recently_added_page_title": "Recién Agregados", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugerencias", "select_user_for_sharing_page_err_album": "Error al crear álbum", "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versión de la Aplicación", "server_info_box_latest_release": "Ultima versión", "server_info_box_server_url": "URL del Servidor", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Cargar imagen de previsualización", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notificar fallos de copia de seguridad en segundo plano: {}", "setting_notifications_notify_hours": "{} horas", @@ -612,6 +639,8 @@ "upload_dialog_info": "¿Quieres respaldar los recursos seleccionados en el servidor?", "upload_dialog_ok": "Subir", "upload_dialog_title": "Subir recurso", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Aceptar", "version_announcement_overlay_release_notes": "notas de la versión", "version_announcement_overlay_text_1": "Hola, amigo, hay una nueva versión de", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Eliminar de la pila", "viewer_stack_use_as_main_asset": "Utilizar como recurso principal", - "viewer_unstack": "Desapilar" + "viewer_unstack": "Desapilar", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index 4f10b4c78b..63521db3a3 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Päivitä", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Lisätty albumiin {album}", "add_to_album_bottom_sheet_already_exists": "Kohde on jo albumissa {album}", "advanced_settings_log_level_title": "Lokitaso: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Katselin", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Laitteen albumit ({})", "backup_album_selection_page_albums_tap": "Napauta sisällyttääksesi, kaksoisnapauta jättääksesi pois", "backup_album_selection_page_assets_scatter": "Kohteet voivat olla hajaantuneina useisiin albumeihin. Albumeita voidaan sisällyttää varmuuskopiointiin tai jättää siitä pois.", @@ -131,6 +137,7 @@ "backup_manual_success": "Onnistui", "backup_manual_title": "Lähetyksen tila", "backup_options_page_title": "Varmuuskopioinnin asetukset", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Kirjastosivun esikatselukuvat ({} kohdetta)", "cache_settings_clear_cache_button": "Tyhjennä välimuisti", "cache_settings_clear_cache_button_title": "Tyhjennä sovelluksen välimuisti. Tämä vaikuttaa merkittävästi sovelluksen suorituskykyyn, kunnes välimuisti on rakennettu uudelleen.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Hallitse paikallista tallenustilaa", "cache_settings_tile_title": "Paikallinen tallennustila", "cache_settings_title": "Välimuistin asetukset", + "cancel": "Cancel", "change_password_form_confirm_password": "Vahvista salasana", "change_password_form_description": "Hei {name},\n\nTämä on joko ensimmäinen kirjautumisesi järjestelmään tai salasanan vaihtaminen vaihtaminen on pakotettu. Ole hyvä ja syötä uusi salasana alle.", "change_password_form_new_password": "Uusi salasana", "change_password_form_password_mismatch": "Salasanat eivät täsmää", "change_password_form_reenter_new_password": "Uusi salasana uudelleen", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Paikat", "curated_object_page_title": "Asiat", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Aikavyöhyke", "edit_image_title": "Edit", "edit_location_dialog_title": "Sijainti", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Lisää kuvaus…", "exif_bottom_sheet_details": "TIEDOT", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Ota käyttöön kokeellinen kuvaruudukko", "experimental_settings_subtitle": "Käyttö omalla vastuulla!", "experimental_settings_title": "Kokeellinen", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Suosikkikohteita ei löytynyt", "favorites_page_title": "Suosikit", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Ota haptinen palaute käyttöön", "haptic_feedback_title": "Haptinen palaute", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Vanhin kuva", "library_page_sort_most_recent_photo": "Viimeisin kuva", "library_page_sort_title": "Albumin otsikko", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Valitse kartalta", "location_picker_latitude": "Leveysaste", "location_picker_latitude_error": "Lisää kelvollinen leveysaste", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Vain luku -tilassa olevien kohteiden päivämäärää ei voitu muokata, ohitetaan", "multiselect_grid_edit_gps_err_read_only": "Vain luku-tilassa olevien kohteiden sijantitietoja ei voitu muokata, ohitetaan", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Ei näytettäviä kohteita", "no_name": "No name", "notification_permission_dialog_cancel": "Peruuta", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Rajoitettu käyttöoikeus. Salliaksesi Immichin varmuuskopioida ja hallita koko kuvakirjastoasi, myönnä oikeus kuviin ja videoihin asetuksista.", "permission_onboarding_request": "Immich vaatii käyttöoikeuden kuvien ja videoiden käyttämiseen.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Asetukset", "profile_drawer_app_logs": "Lokit", "profile_drawer_client_out_of_date_major": "Sovelluksen mobiiliversio on vanhentunut. Päivitä viimeisimpään merkittävään versioon.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Roskakori", "recently_added": "Recently added", "recently_added_page_title": "Viimeksi lisätyt", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Tapahtui virhe", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Ehdotukset", "select_user_for_sharing_page_err_album": "Albumin luonti epäonnistui", "select_user_for_sharing_page_share_suggestions": "Ehdotukset", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Sovelluksen versio", "server_info_box_latest_release": "Viimeisin versio", "server_info_box_server_url": "Palvelimen URL-osoite", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Lataa esikatselukuva", "setting_image_viewer_title": "Kuvat", "setting_languages_apply": "Käytä", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Kieli", "setting_notifications_notify_failures_grace_period": "Ilmoita taustavarmuuskopioinnin epäonnistumisista: {}", "setting_notifications_notify_hours": "{} tunnin välein", @@ -612,6 +639,8 @@ "upload_dialog_info": "Haluatko varmuuskopioida valitut kohteet palvelimelle?", "upload_dialog_ok": "Lähetä", "upload_dialog_title": "Lähetä kohde", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Tiedostan", "version_announcement_overlay_release_notes": "julkaisutiedoissa", "version_announcement_overlay_text_1": "Hei, kaveri! Uusi palvelinversio on saatavilla sovelluksesta", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Poista pinosta", "viewer_stack_use_as_main_asset": "Käytä pääkohteena", - "viewer_unstack": "Pura pino" + "viewer_unstack": "Pura pino", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 9e51cc7cbf..cf2e5dd963 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Ajouté à {album}", "add_to_album_bottom_sheet_already_exists": "Déjà dans {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums sur l'appareil ({})", "backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure", "backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.", @@ -131,6 +137,7 @@ "backup_manual_success": "Succès ", "backup_manual_title": "Statut du téléchargement ", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "vignettes de la page bibliothèque ({} éléments)", "cache_settings_clear_cache_button": "Effacer le cache", "cache_settings_clear_cache_button_title": "Efface le cache de l'application. Cela aura un impact significatif sur les performances de l'application jusqu'à ce que le cache soit reconstruit.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Contrôler le comportement du stockage local", "cache_settings_tile_title": "Stockage local", "cache_settings_title": "Paramètres de mise en cache", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmez le mot de passe", "change_password_form_description": "Bonjour {name},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé de changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", "change_password_form_new_password": "Nouveau mot de passe", "change_password_form_password_mismatch": "Les mots de passe ne correspondent pas", "change_password_form_reenter_new_password": "Saisissez à nouveau le nouveau mot de passe", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Objets", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Ajouter une description...", "exif_bottom_sheet_details": "DÉTAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale", "experimental_settings_subtitle": "Utilisez à vos dépends!", "experimental_settings_title": "Expérimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Aucun élément favori n'a été trouvé", "favorites_page_title": "Favoris", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Photo la plus récente", "library_page_sort_title": "Titre de l'album", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Annuler", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limitée. Pour permettre à Immich de sauvegarder et de gérer l'ensemble de votre bibliothèque, accordez l'autorisation pour les photos et vidéos dans les Paramètres.", "permission_onboarding_request": "Immich demande l'autorisation de visionner vos photos et vidéo", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Journaux", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Corbeille", "recently_added": "Recently added", "recently_added_page_title": "Récemment ajouté", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Échec de la création de l'album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Version de l'application", "server_info_box_latest_release": "Dernière version", "server_info_box_server_url": "URL du serveur", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Charger l'image d'aperçu", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notifier les échecs de la sauvegarde en arrière-plan: {}", "setting_notifications_notify_hours": "{} heures", @@ -612,6 +639,8 @@ "upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur?", "upload_dialog_ok": "Télécharger ", "upload_dialog_title": "Télécharger cet élément ", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Confirmer", "version_announcement_overlay_release_notes": "notes de mise à jour", "version_announcement_overlay_text_1": "Bonjour, une nouvelle version de", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", - "viewer_unstack": "Désempiler" + "viewer_unstack": "Désempiler", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 593dddaeba..1a5646bc2a 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -7,6 +7,7 @@ "action_common_select": "Sélectionner", "action_common_update": "Mise à jour", "add_a_name": "Ajouter un nom", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Ajouté à {album}", "add_to_album_bottom_sheet_already_exists": "Déjà dans {album}", "advanced_settings_log_level_title": "Niveau de log : {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "Élément restauré avec succès", "assets_trashed": "{} élément(s) déplacé(s) vers la corbeille", "assets_trashed_from_server": "{} élément(s) déplacé(s) vers la corbeille du serveur Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Visualisateur d'éléments", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums sur l'appareil ({})", "backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure", "backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.", @@ -131,6 +137,7 @@ "backup_manual_success": "Succès ", "backup_manual_title": "Statut du téléchargement ", "backup_options_page_title": "Options de sauvegarde", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniatures de la page bibliothèque ({} éléments)", "cache_settings_clear_cache_button": "Effacer le cache", "cache_settings_clear_cache_button_title": "Efface le cache de l'application. Cela aura un impact significatif sur les performances de l'application jusqu'à ce que le cache soit reconstruit.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Contrôler le comportement du stockage local", "cache_settings_tile_title": "Stockage local", "cache_settings_title": "Paramètres de mise en cache", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmez le mot de passe", "change_password_form_description": "Bonjour {name},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé à changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", "change_password_form_new_password": "Nouveau mot de passe", "change_password_form_password_mismatch": "Les mots de passe ne correspondent pas", "change_password_form_reenter_new_password": "Saisissez à nouveau le nouveau mot de passe", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "Ok", "client_cert_enter_password": "Entrer mot de passe", "client_cert_import": "Imorted", @@ -199,6 +210,7 @@ "crop": "Recadrer", "curated_location_page_title": "Lieux", "curated_object_page_title": "Objets", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Fuseau horaire", "edit_image_title": "Modifier", "edit_location_dialog_title": "Localisation", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Erreur : {}", "exif_bottom_sheet_description": "Ajouter une description…", "exif_bottom_sheet_details": "DÉTAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale", "experimental_settings_subtitle": "Utilisez à vos dépends !", "experimental_settings_title": "Expérimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoris", "favorites_page_no_favorites": "Aucun élément favori n'a été trouvé", "favorites_page_title": "Favoris", "filename_search": "Nom de fichier ou extension", "filter": "Filtres", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Activer le retour haptique", "haptic_feedback_title": "Retour haptique", "header_settings_add_header_tip": "Ajouter un en-tête", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Photo la plus ancienne", "library_page_sort_most_recent_photo": "Photo la plus récente", "library_page_sort_title": "Titre de l'album", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Sélectionner sur la carte", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Saisir une latitude correcte", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.", "multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.", "my_albums": "Mes albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Aucun élément à afficher", "no_name": "Sans nom", "notification_permission_dialog_cancel": "Annuler", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limitée. Pour permettre à Immich de sauvegarder et de gérer l'ensemble de votre bibliothèque, accordez l'autorisation pour les photos et vidéos dans les Paramètres.", "permission_onboarding_request": "Immich demande l'autorisation de visionner vos photos et vidéo", "places": "Lieux", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Préférences", "profile_drawer_app_logs": "Journaux", "profile_drawer_client_out_of_date_major": "L'application mobile est obsolète. Veuillez effectuer la mise à jour vers la dernière version majeure.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Corbeille", "recently_added": "Récemment ajouté", "recently_added_page_title": "Récemment ajouté", + "save": "Save", "save_to_gallery": "Enregistrer", "scaffold_body_error_occurred": "Une erreur s'est produite", "search_albums": "Rechercher des albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Échec de la création de l'album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Version de l'application", "server_info_box_latest_release": "Dernière version", "server_info_box_server_url": "URL du serveur", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Charger l'image d'aperçu", "setting_image_viewer_title": "Images", "setting_languages_apply": "Appliquer", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Langues", "setting_notifications_notify_failures_grace_period": "Notifier les échecs de la sauvegarde en arrière-plan : {}", "setting_notifications_notify_hours": "{} heures", @@ -612,6 +639,8 @@ "upload_dialog_info": "Voulez-vous sauvegarder la sélection vers le serveur ?", "upload_dialog_ok": "Télécharger ", "upload_dialog_title": "Télécharger cet élément ", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Confirmer", "version_announcement_overlay_release_notes": "notes de mise à jour", "version_announcement_overlay_text_1": "Bonjour, une nouvelle version de", @@ -621,5 +650,7 @@ "videos": "Vidéos", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", - "viewer_unstack": "Désempiler" + "viewer_unstack": "Désempiler", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index c0bfb7367b..5e17cded9c 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -7,6 +7,7 @@ "action_common_select": "בחר", "action_common_update": "עדכון", "add_a_name": "הוסף שם", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "נוסף ל {album}", "add_to_album_bottom_sheet_already_exists": "כבר ב {album}", "advanced_settings_log_level_title": "רמת תיעוד אירועים: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} נכס(ים) שוחזרו בהצלחה", "assets_trashed": "{} נכס(ים) הועברו לאשפה", "assets_trashed_from_server": "{} נכס(ים) הועברו לאשפה משרת ה-Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "מציג הנכסים", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "אלבומים במכשיר ({})", "backup_album_selection_page_albums_tap": "הקש כדי לכלול, הקש פעמיים כדי להחריג", "backup_album_selection_page_assets_scatter": "נכסים יכולים להתפזר על פני אלבומים מרובים. לפיכך, ניתן לכלול או להחריג אלבומים במהלך תהליך הגיבוי", @@ -131,6 +137,7 @@ "backup_manual_success": "הצלחה", "backup_manual_title": "מצב העלאה", "backup_options_page_title": "אפשרויות גיבוי", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "תמונות ממוזערות של דף ספרייה ({} נכסים)", "cache_settings_clear_cache_button": "ניקוי מטמון", "cache_settings_clear_cache_button_title": "מנקה את המטמון של היישום. זה ישפיע באופן משמעותי על הביצועים של היישום עד שהמטמון נבנה מחדש", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "שלוט בהתנהגות האחסון המקומי", "cache_settings_tile_title": "אחסון מקומי", "cache_settings_title": "הגדרות שמירת מטמון", + "cancel": "Cancel", "change_password_form_confirm_password": "אשר סיסמה", "change_password_form_description": "הי {name},\n\nזאת או הפעם הראשונה שאת/ה מתחבר/ת למערכת או שנעשתה בקשה לשינוי הסיסמה שלך. נא להזין את הסיסמה החדשה למטה.", "change_password_form_new_password": "סיסמה חדשה", "change_password_form_password_mismatch": "סיסמאות לא תואמות", "change_password_form_reenter_new_password": "הכנס שוב סיסמה חדשה", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "בסדר", "client_cert_enter_password": "הזן סיסמה", "client_cert_import": "ייבוא", @@ -199,6 +210,7 @@ "crop": "חתוך", "curated_location_page_title": "מקומות", "curated_object_page_title": "דברים", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "אזור זמן", "edit_image_title": "ערוך", "edit_location_dialog_title": "מיקום", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "שגיאה: {}", "exif_bottom_sheet_description": "הוסף תיאור...", "exif_bottom_sheet_details": "פרטים", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "אפשר רשת תמונות ניסיונית", "experimental_settings_subtitle": "השימוש הוא על אחריותך בלבד!", "experimental_settings_title": "נסיוני", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "מועדפים", "favorites_page_no_favorites": "לא נמצאו נכסים מועדפים", "favorites_page_title": "מועדפים", "filename_search": "שם קובץ או סיומת", "filter": "סנן", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "אפשר משוב ברטט", "haptic_feedback_title": "משוב ברטט", "header_settings_add_header_tip": "הוסף כותרת", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "תמונה הכי ישנה", "library_page_sort_most_recent_photo": "תמונה אחרונה ביותר", "library_page_sort_title": "כותרת אלבום", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "בחר על מפה", "location_picker_latitude": "קו רוחב", "location_picker_latitude_error": "הזן קו רוחב חוקי", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "לא ניתן לערוך תאריך של נכס(ים) לקריאה בלבד, מדלג", "multiselect_grid_edit_gps_err_read_only": "לא ניתן לערוך מיקום של נכס(ים) לקריאה בלבד, מדלג", "my_albums": "האלבומים שלי", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "אין נכסים להציג", "no_name": "ללא שם", "notification_permission_dialog_cancel": "ביטול", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "הרשאה מוגבלת. כדי לתת ליישום לגבות ולנהל את כל אוסף הגלריה שלך, הענק הרשאה לתמונות וסרטונים בהגדרות", "permission_onboarding_request": "היישום דורש הרשאה כדי לראות את התמונות והסרטונים שלך", "places": "מקומות", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "העדפות", "profile_drawer_app_logs": "יומן", "profile_drawer_client_out_of_date_major": "האפליקציה לנייד היא מיושנת. נא לעדכן לגרסה הראשית האחרונה", @@ -412,6 +436,7 @@ "profile_drawer_trash": "אשפה", "recently_added": "נוסף לאחרונה", "recently_added_page_title": "נוסף לאחרונה", + "save": "Save", "save_to_gallery": "שמור לגלריה", "scaffold_body_error_occurred": "אירעה שגיאה", "search_albums": "חפש/י אלבומים", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "הצעות", "select_user_for_sharing_page_err_album": "יצירת אלבום נכשלה", "select_user_for_sharing_page_share_suggestions": "הצעות", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "גרסת יישום", "server_info_box_latest_release": "גרסה עדכנית ביותר", "server_info_box_server_url": "כתובת שרת", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "טען תמונת תצוגה מקדימה", "setting_image_viewer_title": "תמונות", "setting_languages_apply": "החל", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "שפות", "setting_notifications_notify_failures_grace_period": "הודע על כשלים בגיבוי ברקע: {}", "setting_notifications_notify_hours": "{} שעות", @@ -612,6 +639,8 @@ "upload_dialog_info": "האם ברצונך לגבות את הנכס(ים) שנבחרו לשרת?", "upload_dialog_ok": "העלאה", "upload_dialog_title": "העלאת נכס", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "אשר", "version_announcement_overlay_release_notes": "הערות פרסום", "version_announcement_overlay_text_1": "הי חבר/ה, יש מהדורה חדשה של", @@ -621,5 +650,7 @@ "videos": "סרטונים", "viewer_remove_from_stack": "הסר מערימה", "viewer_stack_use_as_main_asset": "השתמש כנכס ראשי", - "viewer_unstack": "ביטול ערימה" + "viewer_unstack": "ביטול ערימה", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 104dae2ebd..109192649c 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -3,10 +3,11 @@ "action_common_cancel": "Cancel", "action_common_clear": "Clear", "action_common_confirm": "Confirm", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "सहेजें", + "action_common_select": "चुनें", "action_common_update": "Update", - "add_a_name": "Add a name", + "add_a_name": "नाम जोड़ें", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -22,7 +23,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", - "albums": "Albums", + "albums": "एल्बम", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -38,13 +39,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "साझा करें", "album_viewer_page_share_add_users": "Add users", - "all": "All", + "all": "सभी", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "क्या आप सुनिश्चित हैं कि आप लॉग आउट करना चाहते हैं?", "app_bar_signout_dialog_ok": "हाँ", "app_bar_signout_dialog_title": "लॉग आउट", - "archived": "Archived", + "archived": "संग्रहित", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -58,14 +59,19 @@ "asset_list_layout_sub_title": "Layout", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", - "asset_restored_successfully": "Asset restored successfully", - "assets_deleted_permanently": "{} asset(s) deleted permanently", - "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", - "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", - "assets_restored_successfully": "{} asset(s) restored successfully", - "assets_trashed": "{} asset(s) trashed", - "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_restored_successfully": "संपत्ति(याँ) सफलतापूर्वक पुनर्स्थापित की गईं", + "assets_deleted_permanently": "{} संपत्ति(याँ) स्थायी रूप से हटा दी गईं", + "assets_deleted_permanently_from_server": "{} संपत्ति(याँ) इमिच सर्वर से स्थायी रूप से हटा दी गईं", + "assets_removed_permanently_from_device": "{} संपत्ति(याँ) आपके डिवाइस से स्थायी रूप से हटा दी गईं", + "assets_restored_successfully": "{} संपत्ति(याँ) सफलतापूर्वक पुनर्स्थापित की गईं", + "assets_trashed": "{} संपत्ति(याँ) कचरे में डाली गईं", + "assets_trashed_from_server": "{} संपत्ति(याँ) इमिच सर्वर से कचरे में डाली गईं", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "स्थानीय संग्रहण के व्यवहार को नियंत्रित करें", "cache_settings_tile_title": "स्थानीय संग्रहण", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -168,7 +179,7 @@ "common_create_new_album": "Create new album", "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", "common_shared": "Shared", - "contextual_search": "Sunrise on the beach", + "contextual_search": "समुद्र तट पर सूर्योदय", "control_bottom_app_bar_add_to_album": "Add to album", "control_bottom_app_bar_album_info": "{} items", "control_bottom_app_bar_album_info_shared": "{} items · Shared", @@ -177,8 +188,8 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_download": "Download", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_download": "डाउनलोड", + "control_bottom_app_bar_edit": "संपादित करें", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", "control_bottom_app_bar_favorite": "Favorite", @@ -189,16 +200,17 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", - "create_album": "Create album", + "create_album": "एल्बम बनाएँ", "create_album_page_untitled": "Untitled", - "create_new": "CREATE NEW", + "create_new": "नया बनाएं", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", "create_shared_album_page_share_select_photos": "Select Photos", - "crop": "Crop", + "crop": "छाँटें", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -216,26 +228,27 @@ "delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", - "download_canceled": "Download canceled", - "download_complete": "Download complete", - "download_enqueue": "Download enqueued", - "download_error": "Download Error", - "download_failed": "Download failed", - "download_filename": "file: {}", - "download_finished": "Download finished", - "downloading": "Downloading...", - "downloading_media": "Downloading media", - "download_notfound": "Download not found", - "download_paused": "Download paused", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", - "download_waiting_to_retry": "Waiting to retry", + "download_canceled": "डाउनलोड रद्द कर दिया गया", + "download_complete": "डाउनलोड पूरा", + "download_enqueue": "डाउनलोड कतार में है", + "download_error": "डाउनलोड त्रुटि", + "download_failed": "डाउनलोड विफल", + "download_filename": "फ़ाइल: {}", + "download_finished": "डाउनलोड समाप्त", + "downloading": "डाउनलोड हो रहा है...", + "downloading_media": "मीडिया डाउनलोड हो रहा है", + "download_notfound": "डाउनलोड नहीं मिला", + "download_paused": "डाउनलोड स्थगित", + "download_started": "डाउनलोड प्रारंभ हुआ", + "download_sucess": "डाउनलोड सफल", + "download_sucess_android": "मीडिया DCIM/Immich में डाउनलोड हो गया है", + "download_waiting_to_retry": "पुनः प्रयास करने का इंतजार कर रहा है", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", - "edit_image_title": "Edit", + "edit_image_title": "संपादित करें", "edit_location_dialog_title": "Location", - "error_saving_image": "Error: {}", + "enter_wifi_name": "Enter WiFi name", + "error_saving_image": "त्रुटि: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", - "favorites": "Favorites", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", + "favorites": "पसंदीदा", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", - "filename_search": "File name or extension", - "filter": "Filter", + "filename_search": "फ़ाइल नाम या एक्सटेंशन", + "filter": "फ़िल्टर", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -274,16 +291,16 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "लोकल एसेट्स को लिंक के जरिए शेयर नहीं कर सकते, स्किप कर रहे हैं", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", - "ignore_icloud_photos": "Ignore iCloud photos", - "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "आइक्लाउड फ़ोटो को अनदेखा करें", + "ignore_icloud_photos_description": "आइक्लाउड पर स्टोर की गई फ़ोटोज़ इमिच सर्वर पर अपलोड नहीं की जाएंगी", + "image_saved_successfully": "इमेज सहेज दी गई", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", "image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_share_error": "Share Error", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", - "library": "Library", + "invalid_date": "अमान्य तारीख़", + "invalid_date_format": "अमान्य तारीख़ प्रारूप", + "library": "गैलरी", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -364,16 +385,18 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", - "my_albums": "My albums", + "my_albums": "मेरे एल्बम", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", - "no_name": "No name", + "no_name": "कोई नाम नहीं", "notification_permission_dialog_cancel": "Cancel", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_settings": "Settings", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", - "on_this_device": "On this device", + "on_this_device": "इस डिवाइस पर", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -385,8 +408,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", - "partners": "Partners", - "people": "People", + "partners": "साझेदार", + "people": "लोग", "permission_onboarding_back": "वापस", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -397,7 +420,8 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", - "places": "Places", + "places": "स्थान", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -410,37 +434,38 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", - "recently_added": "Recently added", + "recently_added": "हाल ही में जोड़ा गया", "recently_added_page_title": "Recently Added", - "save_to_gallery": "Save to gallery", + "save": "Save", + "save_to_gallery": "गैलरी में सहेजें", "scaffold_body_error_occurred": "Error occurred", - "search_albums": "Search albums", + "search_albums": "एल्बम खोजें", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", - "search_filter_camera": "Camera", + "search_filter_camera": "कैमरा", "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "कैमरा प्रकार चुनें", + "search_filter_date": "तारीख़", + "search_filter_date_interval": "{start} से {end} तक", + "search_filter_date_title": "तारीख़ की सीमा चुनें", "search_filter_display_option_archive": "Archive", "search_filter_display_option_favorite": "Favorite", "search_filter_display_option_not_in_album": "Not in album", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "प्रदर्शन विकल्प", + "search_filter_display_options_title": "प्रदर्शन विकल्प", + "search_filter_location": "स्थान", "search_filter_location_city": "City", "search_filter_location_country": "Country", "search_filter_location_state": "State", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "स्थान चुनें", + "search_filter_media_type": "मीडिया प्रकार", "search_filter_media_type_all": "All", "search_filter_media_type_image": "Image", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "मीडिया प्रकार चुनें", "search_filter_media_type_video": "Video", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "लोग", + "search_filter_people_title": "लोगों का चयन करें", "search_page_categories": "Categories", "search_page_favorites": "Favorites", "search_page_motion_photos": "Motion Photos", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "लेटेस्ट वर्ज़न", "server_info_box_server_url": "सर्वर URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -560,9 +587,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "साझा किए गए लिंक का प्रबंधन करें", "shared_link_public_album": "Public album", - "shared_links": "Shared links", + "shared_links": "साझा किए गए लिंक", "share_done": "Done", - "shared_with_me": "Shared with me", + "shared_with_me": "मेरे साथ साझा किया गया", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -570,32 +597,32 @@ "sharing_silver_appbar_create_shared_album": "New shared album", "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync": "सिंक करें", + "sync_albums": "एल्बम्स सिंक करें", + "sync_albums_manual_subtitle": "चुने हुए बैकअप एल्बम्स में सभी अपलोड की गई वीडियो और फ़ोटो सिंक करें", + "sync_upload_album_setting_subtitle": "अपनी फ़ोटो और वीडियो बनाएँ और उन्हें इमिच पर चुने हुए एल्बम्स में अपलोड करें", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", "tab_controller_nav_sharing": "Sharing", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", - "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", - "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_colorful_interface_subtitle": "प्राथमिक रंग को पृष्ठभूमि सतहों पर लागू करें", + "theme_setting_colorful_interface_title": "रंगीन इंटरफ़ेस", "theme_setting_dark_mode_switch": "Dark mode", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_title": "Image viewer quality", - "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", - "theme_setting_primary_color_title": "Primary color", - "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_primary_color_subtitle": "प्राथमिक क्रियाओं और उच्चारणों के लिए एक रंग चुनें", + "theme_setting_primary_color_title": "प्राथमिक रंग", + "theme_setting_system_primary_color_title": "सिस्टम रंग का उपयोग करें", "theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_title": "Theme", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", - "trash": "Trash", - "trash_emptied": "Emptied trash", + "trash": "कचरा", + "trash_emptied": "कचरा खाली कर दिया", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", "trash_page_empty_trash_btn": "कूड़ेदान खाली करें", @@ -612,14 +639,18 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "videos": "Videos", + "videos": "वीडियो", "viewer_remove_from_stack": "स्टैक से हटाएं", "viewer_stack_use_as_main_asset": "मुख्य संपत्ति के रूप में उपयोग करें", - "viewer_unstack": "स्टैक रद्द करें" + "viewer_unstack": "स्टैक रद्द करें", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index a19263e2bc..b71cc21321 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -7,6 +7,7 @@ "action_common_select": "Kiválaszt", "action_common_update": "Frissít", "add_a_name": "Név hozzáadása", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Hozzáadva a(z) \"{album}\" albumhoz", "add_to_album_bottom_sheet_already_exists": "Már benne van a(z) \"{album}\" albumban", "advanced_settings_log_level_title": "Naplózás szintje: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} elem sikeresen helyreállítva", "assets_trashed": "{} elem lomtárba helyezve", "assets_trashed_from_server": "{} elem lomtárba helyezve az Immich szerveren", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Elem Megjelenítő", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({})", "backup_album_selection_page_albums_tap": "Koppints a hozzáadáshoz, duplán koppints az eltávolításhoz", "backup_album_selection_page_assets_scatter": "Egy elem több albumban is lehet. Ezért a mentéshez albumokat lehet hozzáadni vagy azokat a mentésből kihagyni.", @@ -131,6 +137,7 @@ "backup_manual_success": "Sikeres", "backup_manual_title": "Feltöltés állapota", "backup_options_page_title": "Biztonági mentés beállításai", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Képtár oldalankénti bélyegképei ({} elem)", "cache_settings_clear_cache_button": "Gyorsítótár kiürítése", "cache_settings_clear_cache_button_title": "Kiüríti az alkalmazás gyorsítótárát. Ez jelentősen kihat az alkalmazás teljesítményére, amíg a gyorsítótár újra nem épül.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Helyi tárhely viselkedésének beállítása", "cache_settings_tile_title": "Helyi Tárhely", "cache_settings_title": "Gyorsítótár Beállítások", + "cancel": "Cancel", "change_password_form_confirm_password": "Jelszó Megerősítése", "change_password_form_description": "Szia {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséges a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", "change_password_form_new_password": "Új Jelszó", "change_password_form_password_mismatch": "A beírt jelszavak nem egyeznek", "change_password_form_reenter_new_password": "Jelszó (Még Egyszer)", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Jelszó Megadása", "client_cert_import": "Importálás", @@ -199,6 +210,7 @@ "crop": "Kivágás", "curated_location_page_title": "Helyek", "curated_object_page_title": "Dolgok", + "current_server_address": "Current server address", "daily_title_text_date": "MMM dd (E)", "daily_title_text_date_year": "yyyy MMM dd (E)", "date_format": "y LLL d (E) • HH:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Időzóna", "edit_image_title": "Szerkesztés", "edit_location_dialog_title": "Hely", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Hiba: {}", "exif_bottom_sheet_description": "Leírás Hozzáadása...", "exif_bottom_sheet_details": "RÉSZLETEK", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Kisérleti képrács engedélyezése", "experimental_settings_subtitle": "Csak saját felelősségre használd!", "experimental_settings_title": "Kísérleti", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Kedvencek", "favorites_page_no_favorites": "Nem található kedvencnek jelölt elem", "favorites_page_title": "Kedvencek", "filename_search": "Fájlnév vagy kiterjesztés", "filter": "Szűrő", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Rezgéses visszajelzés engedélyezése", "haptic_feedback_title": "Rezgéses Visszajelzés", "header_settings_add_header_tip": "Fejléc Hozzáadása", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Legrégebbi fotó", "library_page_sort_most_recent_photo": "Legújabb fotó", "library_page_sort_title": "Album címe", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Válassz a térképen", "location_picker_latitude": "Szélességi kör", "location_picker_latitude_error": "Érvényes szélességi kört írj be", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Csak-olvasható elem(ek) dátuma nem módosítható, ezért kihagyjuk", "multiselect_grid_edit_gps_err_read_only": "Csak-olvasható elem(ek) helye nem módosítható, ezért kihagyjuk", "my_albums": "Saját albumaim", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Nincs megjeleníthető elem", "no_name": "Névtelen", "notification_permission_dialog_cancel": "Mégsem", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Korlátozott hozzáférés. Ha szeretnéd, hogy az Immich a teljes galéria gyűjteményedet mentse és kezelje, akkor a Beállításokban engedélyezd a fotó és videó jogosultságokat.", "permission_onboarding_request": "Engedélyezni kell, hogy az Immich hozzáférjen a képeidhez és videóidhoz", "places": "Helyek", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Beállítások", "profile_drawer_app_logs": "Naplók", "profile_drawer_client_out_of_date_major": "A mobilalkalmazás elavult. Kérjük, frissítsd a legfrisebb főverzióra.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Lomtár", "recently_added": "Nemrég hozzáadott", "recently_added_page_title": "Nemrég Hozzáadott", + "save": "Save", "save_to_gallery": "Mentés a galériába", "scaffold_body_error_occurred": "Hiba történt", "search_albums": "Albumok keresése", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Javaslatok", "select_user_for_sharing_page_err_album": "Az album létrehozása sikertelen", "select_user_for_sharing_page_share_suggestions": "Javaslatok", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Alkalmazás Verzió", "server_info_box_latest_release": "Legfrissebb Verzió", "server_info_box_server_url": "Szerver Címe", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Előnézet betöltése", "setting_image_viewer_title": "Képek", "setting_languages_apply": "Alkalmaz", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Nyelvek", "setting_notifications_notify_failures_grace_period": "Értesítés a háttérben történő mentés hibáiról: {}", "setting_notifications_notify_hours": "{} óra", @@ -612,6 +639,8 @@ "upload_dialog_info": "Szeretnél mentést készíteni a kiválasztott elem(ek)ről a szerverre?", "upload_dialog_ok": "Feltöltés", "upload_dialog_title": "Elem Feltöltése", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Megértettem", "version_announcement_overlay_release_notes": "kiadási megjegyzések áttekintésére", "version_announcement_overlay_text_1": "Szia barátom, ennek az alkalmazásnak van egy új verziója: ", @@ -621,5 +650,7 @@ "videos": "Videók", "viewer_remove_from_stack": "Eltávolít a Csoportból", "viewer_stack_use_as_main_asset": "Fő Elemnek Beállít", - "viewer_unstack": "Csoport Megszűntetése" + "viewer_unstack": "Csoport Megszűntetése", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 3d5c2805f0..524b313a90 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Aggiorna", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Aggiunto in {album}", "add_to_album_bottom_sheet_already_exists": "Già presente in {album}", "advanced_settings_log_level_title": "Livello log: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Visualizzazione risorse", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Album sul dispositivo ({})", "backup_album_selection_page_albums_tap": "Tap per includere, doppio tap per escludere.", "backup_album_selection_page_assets_scatter": "Visto che le risorse possono trovarsi in più album, questi possono essere inclusi o esclusi dal backup.", @@ -131,6 +137,7 @@ "backup_manual_success": "Successo", "backup_manual_title": "Stato del caricamento", "backup_options_page_title": "Opzioni di Backup", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Anteprime pagine librerie ({} risorse)", "cache_settings_clear_cache_button": "Pulisci cache", "cache_settings_clear_cache_button_title": "Pulisce la cache dell'app. Questo impatterà significativamente le prestazioni dell''app fino a quando la cache non sarà rigenerata.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controlla il comportamento dello storage locale", "cache_settings_tile_title": "Archiviazione locale", "cache_settings_title": "Impostazioni della Cache", + "cancel": "Cancel", "change_password_form_confirm_password": "Conferma Password", "change_password_form_description": "Ciao {name},\n\nQuesto è la prima volta che accedi al sistema oppure è stato fatto una richiesta di cambiare la password. Per favore inserisca la nuova password qui sotto", "change_password_form_new_password": "Nuova Password", "change_password_form_password_mismatch": "Le password non coincidono", "change_password_form_reenter_new_password": "Inserisci ancora la nuova password ", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Location", "curated_object_page_title": "Oggetti", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, d LLL, y • hh:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Fuso orario", "edit_image_title": "Edit", "edit_location_dialog_title": "Posizione", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Aggiungi una descrizione...", "exif_bottom_sheet_details": "DETTAGLI", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", "experimental_settings_title": "Sperimentale", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Nessun preferito", "favorites_page_title": "Preferiti", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Abilita feedback aptico", "haptic_feedback_title": "Feedback aptico", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Foto più vecchia", "library_page_sort_most_recent_photo": "Più recente", "library_page_sort_title": "Titolo album", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Scegli una mappa", "location_picker_latitude": "Latitudine", "location_picker_latitude_error": "Inserisci una latitudine valida", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Non puoi modificare la data di risorse in sola lettura, azione ignorata", "multiselect_grid_edit_gps_err_read_only": "Non puoi modificare la posizione di risorse in sola lettura, azione ignorata", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Nessuna risorsa da mostrare", "no_name": "No name", "notification_permission_dialog_cancel": "Annulla", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permessi limitati. Per consentire a Immich di gestire e fare i backup di tutta la galleria, concedi i permessi Foto e Video dalle Impostazioni.", "permission_onboarding_request": "Immich richiede i permessi per vedere le tue foto e video", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferenze", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "L'applicazione non è aggiornata. Per favore aggiorna all'ultima versione principale.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Cestino", "recently_added": "Recently added", "recently_added_page_title": "Aggiunti di recente", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Si è verificato un errore.", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggerimenti ", "select_user_for_sharing_page_err_album": "Impossibile nel creare l'album ", "select_user_for_sharing_page_share_suggestions": "Suggerimenti", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versione App", "server_info_box_latest_release": "Ultima Versione", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Carica immagine di anteprima", "setting_image_viewer_title": "Images", "setting_languages_apply": "Applica", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Lingue", "setting_notifications_notify_failures_grace_period": "Notifica caricamenti falliti in background: {}", "setting_notifications_notify_hours": "{} ore", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vuoi fare il backup sul server delle risorse selezionate?", "upload_dialog_ok": "Carica", "upload_dialog_title": "Carica file", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Presa visione", "version_announcement_overlay_release_notes": "note di rilascio", "version_announcement_overlay_text_1": "Ciao, c'è una nuova versione di", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Rimuovi dalla pila", "viewer_stack_use_as_main_asset": "Usa come risorsa principale", - "viewer_unstack": "Rimuovi dal gruppo" + "viewer_unstack": "Rimuovi dal gruppo", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index bcc1df6548..7843526d2f 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -7,6 +7,7 @@ "action_common_select": "選択", "action_common_update": "更新", "add_a_name": "名前を追加", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "{album}に追加", "add_to_album_bottom_sheet_already_exists": "{album}に追加済み", "advanced_settings_log_level_title": "ログレベル: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{}項目を復元しました", "assets_trashed": "{}項目をゴミ箱に移動しました", "assets_trashed_from_server": "サーバー上の{}項目をゴミ箱に移動しました", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "アセットビューアー", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "端末上のアルバム数: {} ", "backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外", "backup_album_selection_page_assets_scatter": "アルバムを選択・除外してバックアップする写真を選ぶ (同じ写真が複数のアルバムに登録されていることがあるため)", @@ -131,6 +137,7 @@ "backup_manual_success": "成功", "backup_manual_title": "アップロード状況", "backup_options_page_title": "バックアップオプション", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "ライブラリのサムネイル ({}枚)", "cache_settings_clear_cache_button": "キャッシュをクリア", "cache_settings_clear_cache_button_title": "キャッシュを削除 (キャッシュが再生成されるまで、アプリのパフォーマンスが著しく低下します)", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "ローカルストレージの挙動を確認する", "cache_settings_tile_title": "ローカルストレージ", "cache_settings_title": "キャッシュの設定", + "cancel": "Cancel", "change_password_form_confirm_password": "確定", "change_password_form_description": "{name}さん こんにちは\n\nサーバーにアクセスするのが初めてか、パスワードリセットのリクエストがされました。新しいパスワードを入力してください", "change_password_form_new_password": "新しいパスワード", "change_password_form_password_mismatch": "パスワードが一致しません", "change_password_form_reenter_new_password": "再度パスワードを入力してください", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "了解", "client_cert_enter_password": "パスワードを入力", "client_cert_import": "インポート", @@ -199,6 +210,7 @@ "crop": "クロップ", "curated_location_page_title": "撮影場所", "curated_object_page_title": "被写体", + "current_server_address": "Current server address", "daily_title_text_date": "MM DD, EE", "daily_title_text_date_year": "yyyy MM DD, EE", "date_format": "MM DD, EE • hh:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "タイムゾーン", "edit_image_title": "編集", "edit_location_dialog_title": "位置情報", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "エラー: {}", "exif_bottom_sheet_description": "説明を追加", "exif_bottom_sheet_details": "詳細", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "試験的なグリッドを有効化", "experimental_settings_subtitle": "試験的機能につき自己責任で!", "experimental_settings_title": "試験的機能", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "お気に入り", "favorites_page_no_favorites": "お気に入り登録された写真またはビデオがありません", "favorites_page_title": "お気に入り", "filename_search": "ファイル名、又は拡張子", "filter": "フィルター", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "ハプティックフィードバック", "haptic_feedback_title": "ハプティックフィードバックを有効にする", "header_settings_add_header_tip": "ヘッダを追加", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "一番古い項目", "library_page_sort_most_recent_photo": "最近の項目", "library_page_sort_title": "アルバム名", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "マップを選択", "location_picker_latitude": "緯度", "location_picker_latitude_error": "有効な緯度を入力してください", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません", "multiselect_grid_edit_gps_err_read_only": "読み取り専用の項目の位置情報を変更できません", "my_albums": "自分のアルバム", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "表示する項目がありません", "no_name": "名前がありません", "notification_permission_dialog_cancel": "キャンセル", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "写真へのアクセスが制限されています。Immichが写真のバックアップと管理を行うには、システム設定から写真と動画のアクセス権限を変更してください。", "permission_onboarding_request": "Immichは写真へのアクセス許可が必要です", "places": "場所", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "設定", "profile_drawer_app_logs": "ログ", "profile_drawer_client_out_of_date_major": "アプリが更新されてません。最新のバージョンに更新してください", @@ -412,6 +436,7 @@ "profile_drawer_trash": "ゴミ箱", "recently_added": "最近追加された項目", "recently_added_page_title": "最近", + "save": "Save", "save_to_gallery": "ギャラリーに保存", "scaffold_body_error_occurred": "エラーが発生しました", "search_albums": "アルバムを探す", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "ユーザーリスト", "select_user_for_sharing_page_err_album": "アルバム作成に失敗", "select_user_for_sharing_page_share_suggestions": "ユーザ一覧", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "アプリのバージョン", "server_info_box_latest_release": "最新バージョン", "server_info_box_server_url": " サーバーのURL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "プレビューを読み込む", "setting_image_viewer_title": "画像", "setting_languages_apply": "適用する", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "言語", "setting_notifications_notify_failures_grace_period": "バックアップ失敗の通知: {}", "setting_notifications_notify_hours": "{}時間後", @@ -612,6 +639,8 @@ "upload_dialog_info": "選択した項目のバックアップをしますか?", "upload_dialog_ok": "アップロード", "upload_dialog_title": "アップロード", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "了解", "version_announcement_overlay_release_notes": "更新情報", "version_announcement_overlay_text_1": "新しい", @@ -621,5 +650,7 @@ "videos": "動画", "viewer_remove_from_stack": "スタックから外す", "viewer_stack_use_as_main_asset": "メインの画像として使用する", - "viewer_unstack": "スタックを解除" + "viewer_unstack": "スタックを解除", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index 02eace03b0..7ecb3da2fa 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -7,6 +7,7 @@ "action_common_select": "선택", "action_common_update": "업데이트", "add_a_name": "이름 추가", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "{album}에 추가되었습니다.", "add_to_album_bottom_sheet_already_exists": "{album}에 이미 존재하는 항목입니다.", "advanced_settings_log_level_title": "로그 레벨: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "항목 {}개를 복원했습니다.", "assets_trashed": "휴지통으로 항목 {}개가 이동되었습니다.", "assets_trashed_from_server": "휴지통으로 Immich 항목 {}개가 이동되었습니다.", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "보기 옵션", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "기기의 앨범 ({})", "backup_album_selection_page_albums_tap": "한 번 눌러 선택, 두 번 눌러 제외하세요.", "backup_album_selection_page_assets_scatter": "각 항목은 여러 앨범에 포함될 수 있으며, 백업 진행 중에도 대상 앨범을 포함하거나 제외할 수 있습니다.", @@ -131,6 +137,7 @@ "backup_manual_success": "성공", "backup_manual_title": "업로드 상태", "backup_options_page_title": "백업 옵션", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "라이브러리 섬네일 ({})", "cache_settings_clear_cache_button": "캐시 지우기", "cache_settings_clear_cache_button_title": "앱 캐시를 지웁니다. 이 작업은 캐시가 다시 생성될 때까지 앱 성능에 상당한 영향을 미칠 수 있습니다.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "로컬 스토리지 동작 제어", "cache_settings_tile_title": "로컬 스토리지", "cache_settings_title": "캐시 설정", + "cancel": "Cancel", "change_password_form_confirm_password": "현재 비밀번호 입력", "change_password_form_description": "안녕하세요 {name}님,\n\n첫 로그인이거나, 비밀번호가 초기화되어 비밀번호를 설정해야 합니다. 아래에 새 비밀번호를 입력해주세요.", "change_password_form_new_password": "새 비밀번호 입력", "change_password_form_password_mismatch": "비밀번호가 일치하지 않습니다.", "change_password_form_reenter_new_password": "새 비밀번호 확인", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "확인", "client_cert_enter_password": "비밀번호 입력", "client_cert_import": "가져오기", @@ -199,6 +210,7 @@ "crop": "자르기", "curated_location_page_title": "장소", "curated_object_page_title": "사물", + "current_server_address": "Current server address", "daily_title_text_date": "M월 d일 EEEE", "daily_title_text_date_year": "yyyy년 M월 d일 EEEE", "date_format": "yyyy년 M월 d일 EEEE • a h:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "시간대", "edit_image_title": "편집", "edit_location_dialog_title": "위치", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "오류: {}", "exif_bottom_sheet_description": "설명 추가...", "exif_bottom_sheet_details": "상세 정보", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "새 사진 배열 사용 (실험적)", "experimental_settings_subtitle": "본인 책임 하에 사용하세요!", "experimental_settings_title": "실험적", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "즐겨찾기", "favorites_page_no_favorites": "즐겨찾기된 항목 없음", "favorites_page_title": "즐겨찾기", "filename_search": "파일 이름 또는 확장자", "filter": "필터", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "햅틱 피드백 활성화", "haptic_feedback_title": "햅틱 피드백", "header_settings_add_header_tip": "헤더 추가", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "오래된 순", "library_page_sort_most_recent_photo": "최신순", "library_page_sort_title": "앨범 제목", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "지도에서 선택", "location_picker_latitude": "위도", "location_picker_latitude_error": "유효한 위도를 입력하세요.", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "읽기 전용 항목의 날짜는 변경할 수 없습니다. 건너뜁니다.", "multiselect_grid_edit_gps_err_read_only": "읽기 전용 항목의 위치는 변경할 수 없습니다. 건너뜁니다.", "my_albums": "내 앨범", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "표시할 항목 없음", "no_name": "이름 없음", "notification_permission_dialog_cancel": "취소", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "권한이 없습니다. Immich가 전체 갤러리 컬렉션을 백업하고 관리할 수 있도록 하려면 설정에서 사진 및 동영상 권한을 부여하세요.", "permission_onboarding_request": "사진 및 동영상 권한이 필요합니다.", "places": "장소", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "설정", "profile_drawer_app_logs": "로그", "profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "휴지통", "recently_added": "최근 추가", "recently_added_page_title": "최근 추가", + "save": "Save", "save_to_gallery": "갤러리에 저장", "scaffold_body_error_occurred": "문제가 발생했습니다.", "search_albums": "앨범 검색", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "추천", "select_user_for_sharing_page_err_album": "앨범을 생성하지 못했습니다.", "select_user_for_sharing_page_share_suggestions": "제안", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "앱 버전", "server_info_box_latest_release": "최신 버전", "server_info_box_server_url": "서버 URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "미리 보기 이미지 불러오기", "setting_image_viewer_title": "이미지", "setting_languages_apply": "적용", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "언어", "setting_notifications_notify_failures_grace_period": "백그라운드 백업 실패 알림: {}", "setting_notifications_notify_hours": "{}시간 후", @@ -612,6 +639,8 @@ "upload_dialog_info": "선택한 항목을 서버에 백업하시겠습니까?", "upload_dialog_ok": "업로드", "upload_dialog_title": "항목 업로드", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "확인", "version_announcement_overlay_release_notes": "릴리스 노트", "version_announcement_overlay_text_1": "안녕하세요,", @@ -621,5 +650,7 @@ "videos": "동영상", "viewer_remove_from_stack": "스택에서 제거", "viewer_stack_use_as_main_asset": "대표 사진으로 설정", - "viewer_unstack": "스택 해제" + "viewer_unstack": "스택 해제", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index 0075f65de0..6fb2ed4ff5 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -612,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index b49e2f5af7..8b9d793278 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Atjaunināt", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Pievienots {album}", "add_to_album_bottom_sheet_already_exists": "Jau pievienots {album}", "advanced_settings_log_level_title": "Žurnalēšanas līmenis: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Aktīvu Skatītājs", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albumi ierīcē ({})", "backup_album_selection_page_albums_tap": "Pieskarieties, lai iekļautu, veiciet dubultskārienu, lai izslēgtu", "backup_album_selection_page_assets_scatter": "Aktīvi var būt izmētāti pa vairākiem albumiem. Tādējādi dublēšanas procesā albumus var iekļaut vai neiekļaut.", @@ -131,6 +137,7 @@ "backup_manual_success": "Veiksmīgi", "backup_manual_title": "Augšupielādes statuss", "backup_options_page_title": "Dublēšanas iestatījumi", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Bibliotēkas lapu sīktēli ({} aktīvi)", "cache_settings_clear_cache_button": "Iztīrīt kešatmiņu", "cache_settings_clear_cache_button_title": "Iztīra aplikācijas kešatmiņu. Tas būtiski ietekmēs lietotnes veiktspēju, līdz kešatmiņa būs pārbūvēta.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kontrolēt lokālās krātuves uzvedību", "cache_settings_tile_title": "Lokālā Krātuve", "cache_settings_title": "Kešdarbes iestatījumi", + "cancel": "Cancel", "change_password_form_confirm_password": "Apstiprināt Paroli", "change_password_form_description": "Sveiki {name},\n\nŠī ir pirmā reize, kad pierakstāties sistēmā, vai arī ir iesniegts pieprasījums mainīt paroli. Lūdzu, zemāk ievadiet jauno paroli.", "change_password_form_new_password": "Jauna Parole", "change_password_form_password_mismatch": "Paroles nesakrīt", "change_password_form_reenter_new_password": "Atkārtoti ievadīt jaunu paroli", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Vietas", "curated_object_page_title": "Lietas", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, gggg", "date_format": "E, LLL d, g • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Laika zona", "edit_image_title": "Edit", "edit_location_dialog_title": "Atrašanās vieta", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Pievienot Aprakstu...", "exif_bottom_sheet_details": "INFORMĀCIJA", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Iespējot eksperimentālo fotorežģi", "experimental_settings_subtitle": "Izmanto uzņemoties risku!", "experimental_settings_title": "Eksperimentāls", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Nav atrasti iecienītākie aktīvi", "favorites_page_title": "Izlase", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Iestatīt haptisku reakciju", "haptic_feedback_title": "Haptiska Reakcija", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Vecākais fotoattēls", "library_page_sort_most_recent_photo": "Jaunākais fotoattēls", "library_page_sort_title": "Albuma virsraksts", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Izvēlēties uz kartes", "location_picker_latitude": "Ģeogrāfiskais platums", "location_picker_latitude_error": "Ievadiet korektu ģeogrāfisko platumu", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Nevar rediģēt read only aktīva(-u) datumu, notiek izlaišana", "multiselect_grid_edit_gps_err_read_only": "Nevar rediģēt atrašanās vietu read only aktīva(-u) datumu, notiek izlaišana", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Nav uzrādāmo aktīvu", "no_name": "No name", "notification_permission_dialog_cancel": "Atcelt", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Atļauja ierobežota. Lai atļautu Immich dublēšanu un varētu pārvaldīt visu galeriju kolekciju, sadaļā Iestatījumi piešķiriet fotoattēlu un video atļaujas.", "permission_onboarding_request": "Immich nepieciešama atļauja skatīt jūsu fotoattēlus un videoklipus.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Iestatījumi", "profile_drawer_app_logs": "Žurnāli", "profile_drawer_client_out_of_date_major": "Mobilā Aplikācija ir novecojusi. Lūdzu atjaunojiet to uz jaunāko lielo versiju", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Atkritne", "recently_added": "Recently added", "recently_added_page_title": "Nesen Pievienotais", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Radās kļūda", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Ieteikumi", "select_user_for_sharing_page_err_album": "Neizdevās izveidot albumu", "select_user_for_sharing_page_share_suggestions": "Ieteikumi", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Aplikācijas Versija", "server_info_box_latest_release": "Jaunākā Versija", "server_info_box_server_url": "Servera URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Ielādēt priekšskatījuma attēlu", "setting_image_viewer_title": "Attēli", "setting_languages_apply": "Lietot", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Valodas", "setting_notifications_notify_failures_grace_period": "Paziņot par fona dublēšanas kļūmēm: {}", "setting_notifications_notify_hours": "{} stundas", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vai vēlaties veikt izvēlētā(-o) aktīva(-u) dublējumu uz servera?", "upload_dialog_ok": "Augšupielādēt", "upload_dialog_title": "Augšupielādēt Aktīvu", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Atzīt", "version_announcement_overlay_release_notes": "informācija par laidienu", "version_announcement_overlay_text_1": "Sveiks draugs, ir jauns izlaidums no", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Noņemt no Steka", "viewer_stack_use_as_main_asset": "Izmantot kā Galveno Aktīvu", - "viewer_unstack": "At-Stekot" + "viewer_unstack": "At-Stekot", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/mn-MN.json b/mobile/assets/i18n/mn-MN.json index 66392ed47a..ef45a29e99 100644 --- a/mobile/assets/i18n/mn-MN.json +++ b/mobile/assets/i18n/mn-MN.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Цуцлах", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -612,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 80c9db2804..e58b0e1c2e 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -7,6 +7,7 @@ "action_common_select": "Velg", "action_common_update": "Oppdater", "add_a_name": "Legg til navn", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Lagt til i {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", "advanced_settings_log_level_title": "Loggnivå: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} objekt(er) gjenopprettet", "assets_trashed": "{} objekt(er) slettet", "assets_trashed_from_server": "{} objekt(er) slettet fra Immich serveren", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Objektviser", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Trykk for å inkludere, dobbelttrykk for å ekskludere", "backup_album_selection_page_assets_scatter": "Objekter kan bli spredd over flere album. Album kan derfor bli inkludert eller ekskludert under sikkerhetskopieringen.", @@ -131,6 +137,7 @@ "backup_manual_success": "Vellykket", "backup_manual_title": "Opplastingsstatus", "backup_options_page_title": "Backupinnstillinger", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Bibliotekminiatyrbilder ({} objekter)", "cache_settings_clear_cache_button": "Tøm buffer", "cache_settings_clear_cache_button_title": "Tømmer app-ens buffer. Dette vil ha betydelig innvirkning på appens ytelse inntil bufferen er gjenoppbygd.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kontroller lokal lagring", "cache_settings_tile_title": "Lokal lagring", "cache_settings_title": "Bufringsinnstillinger", + "cancel": "Cancel", "change_password_form_confirm_password": "Bekreft passord", "change_password_form_description": "Hei {name}!\n\nDette er enten første gang du logger på systemet, eller det er sendt en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", "change_password_form_new_password": "Nytt passord", "change_password_form_password_mismatch": "Passordene stemmer ikke", "change_password_form_reenter_new_password": "Skriv nytt passord igjen", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Skriv inn passord", "client_cert_import": "Importer", @@ -199,6 +210,7 @@ "crop": "Beskjær", "curated_location_page_title": "Plasseringer", "curated_object_page_title": "Ting", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Tidssone", "edit_image_title": "Endre", "edit_location_dialog_title": "Lokasjon", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Feil: {}", "exif_bottom_sheet_description": "Legg til beskrivelse ...", "exif_bottom_sheet_details": "DETALJER", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Aktiver eksperimentell rutenettsvisning", "experimental_settings_subtitle": "Bruk på egen risiko!", "experimental_settings_title": "Eksperimentelt", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoritter", "favorites_page_no_favorites": "Ingen favorittobjekter funnet", "favorites_page_title": "Favoritter", "filename_search": "Filnavn eller filtype", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Aktivert haptisk tilbakemelding", "haptic_feedback_title": "Haptisk tilbakemelding", "header_settings_add_header_tip": "Legg til header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Eldste bilde", "library_page_sort_most_recent_photo": "Siste bilde", "library_page_sort_title": "Albumtittel", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Velg på kart", "location_picker_latitude": "Breddegrad", "location_picker_latitude_error": "Skriv inn en gyldig bredddegrad", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Kan ikke endre dato på objekt(er) med kun lese-rettigheter, hopper over", "multiselect_grid_edit_gps_err_read_only": "Kan ikke endre lokasjon på objekt(er) med kun lese-rettigheter, hopper over", "my_albums": "Mine albumer", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Ingen objekter å vise", "no_name": "Ingen navn", "notification_permission_dialog_cancel": "Avbryt", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Begrenset tilgang. For å la Immich sikkerhetskopiere og håndtere galleriet, tillatt bilde- og video-tilgang i Innstillinger.", "permission_onboarding_request": "Immich trenger tilgang til å se dine bilder og videoer", "places": "Steder", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Innstillinger", "profile_drawer_app_logs": "Logg", "profile_drawer_client_out_of_date_major": "Mobilapp er utdatert. Vennligst oppdater til nyeste versjon.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Søppelbøtte", "recently_added": "Nylig lagt til", "recently_added_page_title": "Nylig lagt til", + "save": "Save", "save_to_gallery": "Lagre til galleriet", "scaffold_body_error_occurred": "Feil oppstått", "search_albums": "Søk i albumer", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Forslag", "select_user_for_sharing_page_err_album": "Feilet ved oppretting av album", "select_user_for_sharing_page_share_suggestions": "Forslag", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App-versjon", "server_info_box_latest_release": "Siste versjon", "server_info_box_server_url": "Server-adresse", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Last forhåndsvisningsbilde", "setting_image_viewer_title": "Bilder", "setting_languages_apply": "Bekreft", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Språk", "setting_notifications_notify_failures_grace_period": "Varsle om sikkerhetskopieringsfeil i bakgrunnen: {}", "setting_notifications_notify_hours": "{} timer", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vil du utføre backup av valgte objekt(er) til serveren?", "upload_dialog_ok": "Last opp", "upload_dialog_title": "Last opp objekt", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Bekreft", "version_announcement_overlay_release_notes": "endringsloggen", "version_announcement_overlay_text_1": "Hei, det er en ny versjon av", @@ -621,5 +650,7 @@ "videos": "Videoer", "viewer_remove_from_stack": "Fjern fra stabling", "viewer_stack_use_as_main_asset": "Bruk som hovedobjekt", - "viewer_unstack": "avstable" + "viewer_unstack": "avstable", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 2bf277da12..2814144cda 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -7,6 +7,7 @@ "action_common_select": "Selecteren", "action_common_update": "Bijwerken", "add_a_name": "Naam toevoegen", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Toegevoegd aan {album}", "add_to_album_bottom_sheet_already_exists": "Staat al in {album}", "advanced_settings_log_level_title": "Log niveau: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) succesvol hersteld", "assets_trashed": "{} asset(s) naar de prullenbak verplaatst", "assets_trashed_from_server": "{} asset(s) naar de prullenbak verplaatst op de Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Foto weergave", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums op apparaat ({})", "backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten", "backup_album_selection_page_assets_scatter": "Assets kunnen over verschillende albums verdeeld zijn, dus albums kunnen inbegrepen of uitgesloten zijn van het backup proces.", @@ -131,6 +137,7 @@ "backup_manual_success": "Succes", "backup_manual_title": "Uploadstatus", "backup_options_page_title": "Back-up instellingen", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Thumbnails bibliotheekpagina ({} assets)", "cache_settings_clear_cache_button": "Cache wissen", "cache_settings_clear_cache_button_title": "Wist de cache van de app. Dit zal de presentaties van de app aanzienlijk beïnvloeden totdat de cache opnieuw is opgebouwd.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Beheer het gedrag van lokale opslag", "cache_settings_tile_title": "Lokale opslag", "cache_settings_title": "Cache-instellingen", + "cancel": "Cancel", "change_password_form_confirm_password": "Bevestig wachtwoord", "change_password_form_description": "Hallo {name},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", "change_password_form_new_password": "Nieuw wachtwoord", "change_password_form_password_mismatch": "Wachtwoorden komen niet overeen", "change_password_form_reenter_new_password": "Vul het wachtwoord opnieuw in", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "Ok", "client_cert_enter_password": "Voer wachtwoord in", "client_cert_import": "Importeren", @@ -199,6 +210,7 @@ "crop": "Bijsnijden", "curated_location_page_title": "Plaatsen", "curated_object_page_title": "Dingen", + "current_server_address": "Current server address", "daily_title_text_date": "E dd MMM", "daily_title_text_date_year": "E dd MMM yyyy", "date_format": "E d LLL y • H:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Tijdzone", "edit_image_title": "Bewerken", "edit_location_dialog_title": "Locatie", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Fout: {}", "exif_bottom_sheet_description": "Beschrijving toevoegen...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen", "experimental_settings_subtitle": "Gebruik op eigen risico!", "experimental_settings_title": "Experimenteel", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorieten", "favorites_page_no_favorites": "Geen favoriete assets gevonden", "favorites_page_title": "Favorieten", "filename_search": "Bestandsnaam of extensie", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Aanraaktrillingen inschakelen", "haptic_feedback_title": "Aanraaktrillingen", "header_settings_add_header_tip": "Header toevoegen", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oudste foto", "library_page_sort_most_recent_photo": "Meest recente foto", "library_page_sort_title": "Albumtitel", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Kies op kaart", "location_picker_latitude": "Breedtegraad", "location_picker_latitude_error": "Voer een geldige breedtegraad in", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan", "multiselect_grid_edit_gps_err_read_only": "Kan locatie van alleen-lezen asset(s) niet wijzigen, overslaan", "my_albums": "Mijn albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Geen foto's om te laten zien", "no_name": "Geen naam", "notification_permission_dialog_cancel": "Annuleren", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Beperkte toestemming. Geef toestemming tot foto's en video's in Instellingen om Immich een back-up te laten maken van je galerij en deze te beheren.", "permission_onboarding_request": "Immich heeft toestemming nodig om je foto's en video's te bekijken.", "places": "Plaatsen", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Voorkeuren", "profile_drawer_app_logs": "Logboek", "profile_drawer_client_out_of_date_major": "Mobiele app is verouderd. Werk bij naar de nieuwste hoofdversie.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Prullenbak", "recently_added": "Onlangs toegevoegd", "recently_added_page_title": "Recent toegevoegd", + "save": "Save", "save_to_gallery": "Opslaan in galerij", "scaffold_body_error_occurred": "Fout opgetreden", "search_albums": "Albums zoeken", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggesties", "select_user_for_sharing_page_err_album": "Album aanmaken mislukt", "select_user_for_sharing_page_share_suggestions": "Suggesties", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Appversie", "server_info_box_latest_release": "Laatste Versie", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Voorbeeldafbeelding laden", "setting_image_viewer_title": "Afbeeldingen", "setting_languages_apply": "Toepassen", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Taal", "setting_notifications_notify_failures_grace_period": "Fouten van de achtergrond back-up melden: {}", "setting_notifications_notify_hours": "{} uur", @@ -612,6 +639,8 @@ "upload_dialog_info": "Wil je een backup maken van de geselecteerde asset(s) op de server?", "upload_dialog_ok": "Uploaden", "upload_dialog_title": "Asset uploaden", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Bevestig", "version_announcement_overlay_release_notes": "releaseopmerkingen", "version_announcement_overlay_text_1": "Hoi, er is een nieuwe versie beschikbaar van", @@ -621,5 +650,7 @@ "videos": "Video's", "viewer_remove_from_stack": "Verwijder van Stapel", "viewer_stack_use_as_main_asset": "Gebruik als Hoofd Asset", - "viewer_unstack": "Ontstapel" + "viewer_unstack": "Ontstapel", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index 12a7e6faf2..0141f8b5d5 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -7,6 +7,7 @@ "action_common_select": "Wybierz", "action_common_update": "Aktualizuj", "add_a_name": "Dodaj nazwę", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Dodano do {album}", "add_to_album_bottom_sheet_already_exists": "Już w {album}", "advanced_settings_log_level_title": "Poziom dziennika: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": " {} zasoby pomyślnie przywrócono", "assets_trashed": "{} zasoby zostały usunięte", "assets_trashed_from_server": "{} zasoby usunięte z serwera Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Przeglądarka zasobów", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albumy na urządzeniu ({})", "backup_album_selection_page_albums_tap": "Stuknij, aby włączyć, stuknij dwukrotnie, aby wykluczyć", "backup_album_selection_page_assets_scatter": "Pliki mogą być rozproszone w wielu albumach. Dzięki temu albumy mogą być włączane lub wyłączane podczas procesu tworzenia kopii zapasowej.", @@ -131,6 +137,7 @@ "backup_manual_success": "Sukces", "backup_manual_title": "Stan przesyłania", "backup_options_page_title": "Opcje kopi zapasowej", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniatury stron bibliotek ({} zasobów)", "cache_settings_clear_cache_button": "Wyczyść Cache", "cache_settings_clear_cache_button_title": "Czyści pamięć podręczną aplikacji. Wpłynie to znacząco na wydajność aplikacji, dopóki pamięć podręczna nie zostanie odbudowana.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kontroluj zachowanie lokalnego magazynu", "cache_settings_tile_title": "Lokalny magazyn", "cache_settings_title": "Ustawienia Buforowania", + "cancel": "Cancel", "change_password_form_confirm_password": "Potwierdź Hasło", "change_password_form_description": "Cześć {name},\n\nPierwszy raz logujesz się do systemu, albo złożono prośbę o zmianę hasła. Wpisz poniżej nowe hasło.", "change_password_form_new_password": "Nowe Hasło", "change_password_form_password_mismatch": "Hasła nie są zgodne", "change_password_form_reenter_new_password": "Wprowadź ponownie Nowe Hasło", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Wprowadź hasło", "client_cert_import": "Importuj", @@ -199,6 +210,7 @@ "crop": "Przytnij", "curated_location_page_title": "Miejsca", "curated_object_page_title": "Rzeczy", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Strefa czasowa", "edit_image_title": "Edytuj", "edit_location_dialog_title": "Lokalizacja", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Błąd: {}", "exif_bottom_sheet_description": "Dodaj Opis...", "exif_bottom_sheet_details": "SZCZEGÓŁY", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Włącz eksperymentalną układ zdjęć", "experimental_settings_subtitle": "Używaj na własne ryzyko!", "experimental_settings_title": "Eksperymentalny", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Ulubione", "favorites_page_no_favorites": "Nie znaleziono ulubionych zasobów", "favorites_page_title": "Ulubione", "filename_search": "Nazwa pliku lub rozszerzenie", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Dodaj nagłówek", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Najstarsze zdjęcie", "library_page_sort_most_recent_photo": "Najnowsze zdjęcie", "library_page_sort_title": "Tytuł albumu", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Wybierz na mapie", "location_picker_latitude": "Szerokość geograficzna", "location_picker_latitude_error": "Wprowadź prawidłową szerokość geograficzną", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Nie można edytować daty zasobów tylko do odczytu, pomijanie", "multiselect_grid_edit_gps_err_read_only": "Nie można edytować lokalizacji zasobów tylko do odczytu, pomijanie", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Brak zasobów do pokazania", "no_name": "Bez nazwy", "notification_permission_dialog_cancel": "Anuluj", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Pozwolenie ograniczone. Aby umożliwić Immichowi tworzenie kopii zapasowych całej kolekcji galerii i zarządzanie nią, przyznaj uprawnienia do zdjęć i filmów w Ustawieniach.", "permission_onboarding_request": "Immich potrzebuje pozwolenia na przeglądanie Twoich zdjęć i filmów.", "places": "Miejsca", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Ustawienia", "profile_drawer_app_logs": "Logi", "profile_drawer_client_out_of_date_major": "Aplikacja mobilna jest nieaktualna. Zaktualizuj do najnowszej wersji głównej.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Kosz", "recently_added": "Recently added", "recently_added_page_title": "Ostatnio Dodane", + "save": "Save", "save_to_gallery": "Zapisz w galerii", "scaffold_body_error_occurred": "Wystąpił błąd", "search_albums": "Przeszukaj albumy", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Propozycje", "select_user_for_sharing_page_err_album": "Nie udało się utworzyć albumu", "select_user_for_sharing_page_share_suggestions": "Propozycje", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Wersja Aplikacji", "server_info_box_latest_release": "Ostatnia wersja", "server_info_box_server_url": "Adres URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Załaduj obraz podglądu", "setting_image_viewer_title": "Zdjęcia", "setting_languages_apply": "Zastosuj", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Języki", "setting_notifications_notify_failures_grace_period": "Powiadomienie o awariach kopii zapasowych w tle: {}", "setting_notifications_notify_hours": "{} godzin", @@ -612,6 +639,8 @@ "upload_dialog_info": "Czy chcesz wykonać kopię zapasową wybranych zasobów na serwerze?", "upload_dialog_ok": "Prześlij", "upload_dialog_title": "Prześlij Zasób", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Potwierdzam", "version_announcement_overlay_release_notes": "informacje o wydaniu", "version_announcement_overlay_text_1": "Cześć przyjacielu, jest nowe wydanie", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Usuń ze stosu", "viewer_stack_use_as_main_asset": "Użyj jako głównego zasobu", - "viewer_unstack": "Usuń stos" + "viewer_unstack": "Usuń stos", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index a17bae5567..aa8654e259 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -7,6 +7,7 @@ "action_common_select": "Selecionar", "action_common_update": "Atualizar", "add_a_name": "Adicionar nome", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Adicionado a {album}", "add_to_album_bottom_sheet_already_exists": "Já existe em {album}", "advanced_settings_log_level_title": "Nível de log: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} arquivo(s) restaurados com sucesso", "assets_trashed": "{} arquivo(s) enviados para a lixeira", "assets_trashed_from_server": "{} arquivo(s) do servidor foram enviados para a lixeira", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Visualizador", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, duplo toque para excluir", "backup_album_selection_page_assets_scatter": "Os arquivos podem estar espalhados em vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.", @@ -131,6 +137,7 @@ "backup_manual_success": "Sucesso", "backup_manual_title": "Estado do envio", "backup_options_page_title": "Opções de backup", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturas da página da biblioteca ({} arquivos)", "cache_settings_clear_cache_button": "Limpar cache", "cache_settings_clear_cache_button_title": "Limpa o cache do aplicativo. Isso afetará significativamente o desempenho do aplicativo até que o cache seja reconstruído.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controlar o comportamento do armazenamento local", "cache_settings_tile_title": "Armazenamento local", "cache_settings_title": "Configurações de cache", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirme a senha", "change_password_form_description": "Esta é a primeira vez que você está acessando o sistema ou foi feita uma solicitação para alterar sua senha. Por favor, insira a nova senha abaixo.", "change_password_form_new_password": "Nova senha", "change_password_form_password_mismatch": "As senhas não estão iguais", "change_password_form_reenter_new_password": "Confirme a nova senha", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Digite a senha", "client_cert_import": "Importar", @@ -199,6 +210,7 @@ "crop": "Cortar", "curated_location_page_title": "Locais", "curated_object_page_title": "Objetos", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, d LLL, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Fuso horário", "edit_image_title": "Editar", "edit_location_dialog_title": "Localização", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Erro: {}", "exif_bottom_sheet_description": "Adicionar Descrição...", "exif_bottom_sheet_details": "DETALHES", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Ativar visualização de grade experimental", "experimental_settings_subtitle": "Use por sua conta e risco!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoritos", "favorites_page_no_favorites": "Nenhum favorito encontrado", "favorites_page_title": "Favoritos", "filename_search": "Nome do arquivo ou extensão", "filter": "Filtro", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Habilitar vibração", "haptic_feedback_title": "Vibração", "header_settings_add_header_tip": "Adicionar cabeçalho", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Foto mais antiga", "library_page_sort_most_recent_photo": "Foto mais recente", "library_page_sort_title": "Título do álbum", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Escolha no mapa", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Digite uma latitude válida", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Não é possível editar a data de arquivo só leitura, ignorando", "multiselect_grid_edit_gps_err_read_only": "Não é possível editar a localização de arquivo só leitura, ignorando", "my_albums": "Meus álbuns", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Não há arquivos para exibir", "no_name": "Sem nome", "notification_permission_dialog_cancel": "Cancelar", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permissão limitada. Para permitir que o Immich faça backups e gerencie sua galeria, conceda permissões para fotos e vídeos nas configurações.", "permission_onboarding_request": "O Immich requer autorização para ver as suas fotos e vídeos.", "places": "Lugares", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferências", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "O aplicativo está desatualizado. Por favor, atualize para a versão mais recente.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Lixeira", "recently_added": "Adicionados Recentemente", "recently_added_page_title": "Adicionado recentemente", + "save": "Save", "save_to_gallery": "Salvar na galeria", "scaffold_body_error_occurred": "Ocorreu um erro", "search_albums": "Pesquisar Álbuns", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugestões", "select_user_for_sharing_page_err_album": "Falha ao criar o álbum", "select_user_for_sharing_page_share_suggestions": "Sugestões", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versão do app", "server_info_box_latest_release": "Versão mais recente", "server_info_box_server_url": "URL do servidor", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Carregar imagem de pré-visualização", "setting_image_viewer_title": "Imagens", "setting_languages_apply": "Aplicar", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Idioma", "setting_notifications_notify_failures_grace_period": "Notifique falhas de backup em segundo plano: {}", "setting_notifications_notify_hours": "{} horas", @@ -612,6 +639,8 @@ "upload_dialog_info": "Deseja fazer o backup dos arquivos selecionados no servidor?", "upload_dialog_ok": "Enviar", "upload_dialog_title": "Enviar arquivo", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Entendi", "version_announcement_overlay_release_notes": "notas da versão", "version_announcement_overlay_text_1": "Olá, há um novo lançamento de", @@ -621,5 +650,7 @@ "videos": "Vídeos", "viewer_remove_from_stack": "Remover da pilha", "viewer_stack_use_as_main_asset": "Usar como foto principal", - "viewer_unstack": "Desempilhar" + "viewer_unstack": "Desempilhar", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 2559402633..38f0139ba6 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Actualizează", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Adăugat în {album}", "add_to_album_bottom_sheet_already_exists": "Deja în {album}", "advanced_settings_log_level_title": "Nivel log: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albume în dispozitiv ({})", "backup_album_selection_page_albums_tap": "Apasă odata pentru a include, de două ori pentru a exclude", "backup_album_selection_page_assets_scatter": "Resursele pot fi împrăștiate în mai multe albume. Prin urmare, albumele pot fi incluse sau excluse în timpul procesului de backup.", @@ -131,6 +137,7 @@ "backup_manual_success": "Succes", "backup_manual_title": "Status încărcare", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniaturi pagină galerie ({} resurse)", "cache_settings_clear_cache_button": "Șterge cache", "cache_settings_clear_cache_button_title": "Șterge memoria cache a aplicatiei. Performanța aplicației va fi semnificativ afectată până când va fi reconstruită.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Controlează modul stocării locale", "cache_settings_tile_title": "Stocare locală", "cache_settings_title": "Setări pentru memoria cache", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirmă parola", "change_password_form_description": "Salut {name},\n\nAceasta este fie prima dată când te conectazi la sistem, fie s-a făcut o cerere pentru schimbarea parolei. Te rugăm să introduci noua parolă mai jos.", "change_password_form_new_password": "Parolă nouă", "change_password_form_password_mismatch": "Parolele nu se potrivesc", "change_password_form_reenter_new_password": "Reintrodu noua parolă", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Locuri", "curated_object_page_title": "Obiecte", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Fus orar", "edit_image_title": "Edit", "edit_location_dialog_title": "Locație", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Adaugă Descriere...", "exif_bottom_sheet_details": "DETALII", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Activează grila experimentală de fotografii.", "experimental_settings_subtitle": "Folosește pe propria răspundere!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Nu au fost găsite resurse favorite", "favorites_page_title": "Favorite", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Cea mai veche fotografie", "library_page_sort_most_recent_photo": "Cea mai recentă fotografie", "library_page_sort_title": "Titlu album", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Alege pe hartă", "location_picker_latitude": "Latitudine", "location_picker_latitude_error": "Introdu o latitudine validă", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Nu se poate edita data fișierului(lor) cu permisiuni doar pentru citire, omitere", "multiselect_grid_edit_gps_err_read_only": "Nu se poate edita locația fișierului(lor) cu permisiuni doar pentru citire, omitere", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Anulează", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permisiune limitată. Pentru a permite Immich să facă copii de siguranță și să gestioneze întreaga colecție de galerii, acordă permisiuni pentru fotografii și videoclipuri în Setări.", "permission_onboarding_request": "Immich necesită permisiunea de a vizualiza fotografiile și videoclipurile tale.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Log-uri", "profile_drawer_client_out_of_date_major": "Aplicația nu folosește ultima versiune. Te rugăm să actulizezi la ultima versiune majoră.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Coș", "recently_added": "Recently added", "recently_added_page_title": "Adăugate recent", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "A apărut o eroare", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugestii", "select_user_for_sharing_page_err_album": "Creare album eșuată", "select_user_for_sharing_page_share_suggestions": "Sugestii", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Versiune Aplicatie", "server_info_box_latest_release": "Ultima versiune", "server_info_box_server_url": "URL-ul server-ului", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Încarcă imaginea de previzualizare", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notificare eșuări backup în fundal: {}", "setting_notifications_notify_hours": "{} ore", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vrei să backup resursele selectate pe server?", "upload_dialog_ok": "Incarcă", "upload_dialog_title": "Încarcă resursă", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Confirm", "version_announcement_overlay_release_notes": "informații update", "version_announcement_overlay_text_1": "Salut, există un update nou pentru", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Șterge din grup", "viewer_stack_use_as_main_asset": "Folosește ca resursă principală", - "viewer_unstack": "Anulează grup" + "viewer_unstack": "Anulează grup", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index c79fdfb8c3..f1089834bf 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -7,6 +7,7 @@ "action_common_select": "Выбрать", "action_common_update": "Обновить", "add_a_name": "Добавить имя", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", "advanced_settings_log_level_title": "Уровень логирования:", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} объект(ы) успешно восстановлен(ы)", "assets_trashed": "{} объект(ы) помещен(ы) в корзину", "assets_trashed_from_server": "{} объект(ы) помещен(ы) в корзину на сервере Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Просмотр изображений", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Альбомы на устройстве ({})", "backup_album_selection_page_albums_tap": "Нажмите, чтобы включить,\nнажмите дважды, чтобы исключить", "backup_album_selection_page_assets_scatter": "Ваши изображения и видео могут находиться в разных альбомах. Вы можете выбрать, какие альбомы включить, а какие исключить из резервного копирования.", @@ -131,6 +137,7 @@ "backup_manual_success": "Успешно", "backup_manual_title": "Статус загрузки", "backup_options_page_title": "Резервное копирование", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Миниатюры страниц библиотеки ({} объектов)", "cache_settings_clear_cache_button": "Очистить кэш", "cache_settings_clear_cache_button_title": "Очищает кэш приложения. Это негативно повлияет на производительность, пока кэш не будет создан заново.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Управление локальным хранилищем", "cache_settings_tile_title": "Локальное хранилище", "cache_settings_title": "Настройки кэширования", + "cancel": "Cancel", "change_password_form_confirm_password": "Подтвердите пароль", "change_password_form_description": "Привет, {name}!\n\nЛибо ваш первый вход в систему, либо вы запросили смену пароля. Пожалуйста, введите новый пароль ниже.", "change_password_form_new_password": "Новый пароль", "change_password_form_password_mismatch": "Пароли не совпадают", "change_password_form_reenter_new_password": "Повторно введите новый пароль", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Введите пароль", "client_cert_import": "Импорт", @@ -199,6 +210,7 @@ "crop": "Обрезать", "curated_location_page_title": "Места", "curated_object_page_title": "Предметы", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Часовой пояс", "edit_image_title": "Редактировать", "edit_location_dialog_title": "Местоположение", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Ошибка: {}", "exif_bottom_sheet_description": "Добавить описание...", "exif_bottom_sheet_details": "ПОДРОБНОСТИ", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий", "experimental_settings_subtitle": "Используйте на свой страх и риск!", "experimental_settings_title": "Экспериментальные функции", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Избранное", "favorites_page_no_favorites": "В избранном сейчас пусто", "favorites_page_title": "Избранное", "filename_search": "Имя или расширение файла", "filter": "Фильтр", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Включить тактильную отдачу", "haptic_feedback_title": "Тактильная отдача", "header_settings_add_header_tip": "Добавить заголовок", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Старые фото", "library_page_sort_most_recent_photo": "Последние фото", "library_page_sort_title": "Название альбома", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Выбрать на карте", "location_picker_latitude": "Широта", "location_picker_latitude_error": "Укажите правильную широту", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Невозможно изменить дату файлов только для чтения, пропуск", "multiselect_grid_edit_gps_err_read_only": "Невозможно изменить местоположение файлов только для чтения, пропуск", "my_albums": "Мои альбомы", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Медиа отсутствуют", "no_name": "Без имени", "notification_permission_dialog_cancel": "Отмена", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Доступ к файлам ограничен. Чтобы Immich мог создавать резервные копии и управлять вашей галереей, пожалуйста, предоставьте приложению разрешение на доступ к \"Фото и видео\" в настройках.", "permission_onboarding_request": "Приложению необходимо разрешение на доступ к вашим фото и видео", "places": "Места", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Параметры", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Версия мобильного приложения устарела. Пожалуйста, обновите его.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Корзина", "recently_added": "Недавно добавленные", "recently_added_page_title": "Недавно добавленные", + "save": "Save", "save_to_gallery": "Сохранить в галерею", "scaffold_body_error_occurred": "Возникла ошибка", "search_albums": "Поиск альбома", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Предложения", "select_user_for_sharing_page_err_album": "Не удалось создать альбом", "select_user_for_sharing_page_share_suggestions": "Предложения", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Версия приложения", "server_info_box_latest_release": "Последняя версия", "server_info_box_server_url": "URL сервера", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Загружать уменьшенное изображение", "setting_image_viewer_title": "Изображения", "setting_languages_apply": "Применить", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Язык", "setting_notifications_notify_failures_grace_period": "Уведомлять об ошибках фонового резервного копирования: {}", "setting_notifications_notify_hours": "{} ч.", @@ -612,6 +639,8 @@ "upload_dialog_info": "Хотите создать резервную копию выбранных объектов на сервере?", "upload_dialog_ok": "Загрузить", "upload_dialog_title": "Загрузить объект", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Понятно", "version_announcement_overlay_release_notes": "примечания к выпуску", "version_announcement_overlay_text_1": "Привет, друг! Вышла новая версия", @@ -621,5 +650,7 @@ "videos": "Видео", "viewer_remove_from_stack": "Удалить из стека", "viewer_stack_use_as_main_asset": "Использовать в качестве основного объекта", - "viewer_unstack": "Разобрать стек" + "viewer_unstack": "Разобрать стек", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index eb4e304f2d..ccea3c99f1 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Aktualizovať", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Pridané do {album}", "add_to_album_bottom_sheet_already_exists": "Už v {album}", "advanced_settings_log_level_title": "Úroveň logovania: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Zobrazovač položiek", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albumy v zariadení ({})", "backup_album_selection_page_albums_tap": "Ťuknutím na položku ju zahrniete, dvojitým ťuknutím ju vylúčite", "backup_album_selection_page_assets_scatter": "Súbory môžu byť roztrúsené vo viacerých albumoch. To umožňuje zahrnúť alebo vylúčiť albumy počas procesu zálohovania.", @@ -131,6 +137,7 @@ "backup_manual_success": "Úspech", "backup_manual_title": "Stav nahrávania", "backup_options_page_title": "Možnosti zálohovania", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Náhľady stránok knižnice (položiek {})", "cache_settings_clear_cache_button": "Vymazať vyrovnávaciu pamäť", "cache_settings_clear_cache_button_title": "Vymaže vyrovnávaciu pamäť aplikácie. To výrazne ovplyvní výkon aplikácie, kým sa vyrovnávacia pamäť neobnoví.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Ovládanie správania lokálneho úložiska", "cache_settings_tile_title": "Lokálne úložisko", "cache_settings_title": "Nastavenia vyrovnávacej pamäte", + "cancel": "Cancel", "change_password_form_confirm_password": "Potvrďte heslo", "change_password_form_description": "Dobrý deň, {name},\n\nBuď sa do systému prihlasujete prvýkrát, alebo bola podaná žiadosť o zmenu hesla. Prosím, zadajte nové heslo nižšie.", "change_password_form_new_password": "Nové heslo", "change_password_form_password_mismatch": "Heslá sa nezhodujú", "change_password_form_reenter_new_password": "Znova zadajte nové heslo", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Miesta", "curated_object_page_title": "Veci", + "current_server_address": "Current server address", "daily_title_text_date": "EEEE, d. MMMM", "daily_title_text_date_year": "EEEE, d. MMMM y", "date_format": "EEEE, d. MMMM y • H:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Edit", "edit_location_dialog_title": "Poloha", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Pridať popis...", "exif_bottom_sheet_details": "PODROBNOSTI", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií", "experimental_settings_subtitle": "Používajte na vlastné riziko!", "experimental_settings_title": "Experimentálne", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Žiadne obľúbené médiá", "favorites_page_title": "Obľúbené", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Povoliť hmatovú odozvu", "haptic_feedback_title": "Hmatová odozva", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Najstaršia fotka", "library_page_sort_most_recent_photo": "Najnovšia fotka", "library_page_sort_title": "Podľa názvu albumu", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Zvoľte mapu", "location_picker_latitude": "Zemepisná dĺžka", "location_picker_latitude_error": "Zadajte platnú zemepisnú dĺžku", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Nemožno upraviť dátum položky len na čítanie, preskakujem", "multiselect_grid_edit_gps_err_read_only": "Nemožno upraviť polohu položky len na čítanie, preskakujem", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Žiadne položky", "no_name": "No name", "notification_permission_dialog_cancel": "Zrušiť", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Povolenie obmedzené. Ak chcete, aby Immich zálohoval a spravoval celú vašu zbierku galérie, udeľte v Nastaveniach povolenia na fotografie a videá.", "permission_onboarding_request": "Immich vyžaduje povolenie na prezeranie vašich fotografií a videí.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferencie", "profile_drawer_app_logs": "Logy", "profile_drawer_client_out_of_date_major": "Mobilná aplikácia je zastaralá. Prosím aktualizujte na najnovšiu verziu.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Kôš", "recently_added": "Recently added", "recently_added_page_title": "Nedávno pridané", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Vyskytla sa chyba", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Návrhy", "select_user_for_sharing_page_err_album": "Nepodarilo sa vytvoriť album", "select_user_for_sharing_page_share_suggestions": "Návrhy", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Verzia aplikácie", "server_info_box_latest_release": "Najnovšia verzia", "server_info_box_server_url": "URL Serveru", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Načítať náhľad obrázka", "setting_image_viewer_title": "Obrázky", "setting_languages_apply": "Použiť", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Jazyky", "setting_notifications_notify_failures_grace_period": "Oznámenie o zlyhaní zálohovania na pozadí: {}", "setting_notifications_notify_hours": "{} hodín", @@ -612,6 +639,8 @@ "upload_dialog_info": "Chcete zálohovať zvolené médiá na server?", "upload_dialog_ok": "Nahrať", "upload_dialog_title": "Nahrať médiá", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Potvrdiť", "version_announcement_overlay_release_notes": "poznámky k vydaniu", "version_announcement_overlay_text_1": "Ahoj, je tu nová verzia", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Odstrániť zo zoskupenia", "viewer_stack_use_as_main_asset": "Použiť ako hlavnú fotku", - "viewer_unstack": "Odskupiť" + "viewer_unstack": "Odskupiť", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index 1d7ef33a4e..bc9bd2405d 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Posodobi", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Dodano v {album}", "add_to_album_bottom_sheet_already_exists": "Že v {albumu}", "advanced_settings_log_level_title": "Nivo dnevnika: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Pregledovalnik sredstev", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albumi v napravi ({})", "backup_album_selection_page_albums_tap": "Tapnite za vključitev, dvakrat tapnite za izključitev", "backup_album_selection_page_assets_scatter": "Sredstva so lahko razpršena po več albumih. Tako je mogoče med postopkom varnostnega kopiranja albume vključiti ali izključiti.", @@ -131,6 +137,7 @@ "backup_manual_success": "Uspeh", "backup_manual_title": "Status nalaganja", "backup_options_page_title": "Možnosti varnostne kopije", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Sličice strani knjižnice ({} sredstev)", "cache_settings_clear_cache_button": "Počisti predpomnilnik", "cache_settings_clear_cache_button_title": "Počisti predpomnilnik aplikacije. To bo znatno vplivalo na delovanje aplikacije, dokler se predpomnilnik ne obnovi.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Nadzoruj vedenje lokalnega shranjevanja", "cache_settings_tile_title": "Lokalna shramba", "cache_settings_title": "Nastavitve predpomnjenja", + "cancel": "Cancel", "change_password_form_confirm_password": "Potrdi geslo", "change_password_form_description": "Pozdravljeni {name},\n\nTo je bodisi prvič, da se vpisujete v sistem ali pa je bila podana zahteva za spremembo vašega gesla. Spodaj vnesite novo geslo.", "change_password_form_new_password": "Novo geslo", "change_password_form_password_mismatch": "Gesli se ne ujemata", "change_password_form_reenter_new_password": "Znova vnesi novo geslo", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Lokacije", "curated_object_page_title": "Stvari", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Časovni pas", "edit_image_title": "Edit", "edit_location_dialog_title": "Lokacija", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Dodaj opis..", "exif_bottom_sheet_details": "PODROBNOSTI", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij", "experimental_settings_subtitle": "Uporabljajte na lastno odgovornost!", "experimental_settings_title": "Eksperimentalno", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Ni priljubljenih sredstev", "favorites_page_title": "Priljubljene", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Uporabi haptičen odziv", "haptic_feedback_title": "Haptičen odziv", "header_settings_add_header_tip": "Dodaj glavo", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Najstarejša fotografija", "library_page_sort_most_recent_photo": "Najnovejša fotografija", "library_page_sort_title": "Naslov albuma", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Izberi na zemljevidu", "location_picker_latitude": "Zemljepisna širina", "location_picker_latitude_error": "Vnesi veljavno zemljepisno širino", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Ni mogoče urediti datuma sredstev samo za branje, preskočim", "multiselect_grid_edit_gps_err_read_only": "Ni mogoče urediti lokacije sredstev samo za branje, preskočim", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Ni sredstev za prikaz", "no_name": "No name", "notification_permission_dialog_cancel": "Prekliči", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Dovoljenje je omejeno. Če želite Immichu dovoliti varnostno kopiranje in upravljanje vaše celotne zbirke galerij, v nastavitvah podelite dovoljenja za fotografije in videoposnetke.", "permission_onboarding_request": "Immich potrebuje dovoljenje za ogled vaših fotografij in videoposnetkov.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Nastavitve", "profile_drawer_app_logs": "Dnevniki", "profile_drawer_client_out_of_date_major": "Mobilna aplikacija je zastarela. Posodobite na najnovejšo glavno različico.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Smetnjak", "recently_added": "Recently added", "recently_added_page_title": "Nedavno dodano", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Prišlo je do napake", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Predlogi", "select_user_for_sharing_page_err_album": "Albuma ni bilo mogoče ustvariti", "select_user_for_sharing_page_share_suggestions": "Predlogi", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Različica aplikacije", "server_info_box_latest_release": "Zadnja verzija", "server_info_box_server_url": "URL strežnika", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Naloži predogled slike", "setting_image_viewer_title": "Slike", "setting_languages_apply": "Uporabi", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Jeziki", "setting_notifications_notify_failures_grace_period": "Obvesti o napakah varnostnega kopiranja v ozadju: {}", "setting_notifications_notify_hours": "{} ur", @@ -612,6 +639,8 @@ "upload_dialog_info": "Ali želite varnostno kopirati izbrana sredstva na strežnik?", "upload_dialog_ok": "Naloži", "upload_dialog_title": "Naloži sredstvo", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Preverite", "version_announcement_overlay_release_notes": "opombe ob izdaji", "version_announcement_overlay_text_1": "Živjo prijatelj, na voljo je nova izdaja", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Odstrani iz sklada", "viewer_stack_use_as_main_asset": "Uporabi kot glavno sredstvo", - "viewer_unstack": "Razkladi" + "viewer_unstack": "Razkladi", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index 0075f65de0..6fb2ed4ff5 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -612,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 3e11d73e08..9c3058ee94 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Dodato u {album}", "add_to_album_bottom_sheet_already_exists": "Već u {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albuma na uređaju ({})", "backup_album_selection_page_albums_tap": "Dodirni da uključiš, dodirni dvaput da isključiš", "backup_album_selection_page_assets_scatter": "Zapisi se mogu naći u više različitih albuma. Odatle albumi se mogu uključiti ili isključiti tokom procesa pravljenja pozadinskih kopija.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Sličice na stranici biblioteke", "cache_settings_clear_cache_button": "Obriši keš memoriju", "cache_settings_clear_cache_button_title": "Ova opcija briše keš memoriju aplikacije. Ovo će bitno uticati na performanse aplikacije dok se keš memorija ne učita ponovo.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Opcije za keširanje", + "cancel": "Cancel", "change_password_form_confirm_password": "Ponovo unesite šifru", "change_password_form_description": "Ćao, {name}\n\nOvo je verovatno Vaše prvo pristupanje sistemu, ili je podnešen zahtev za promenu šifre. Molimo Vas, unesite novu šifru ispod", "change_password_form_new_password": "Nova šifra", "change_password_form_password_mismatch": "Šifre se ne podudaraju", "change_password_form_reenter_new_password": "Ponovo unesite novu šifru", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Dodaj opis...", "exif_bottom_sheet_details": "DETALJI", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Aktiviraj eksperimentalni mrežni prikaz fotografija", "experimental_settings_subtitle": "Koristiti na sopstvenu odgovornost!", "experimental_settings_title": "Eksperimentalno", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Omiljeno", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Naziv albuma", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Odustani", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Evidencija", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Sugsetije", "select_user_for_sharing_page_err_album": "Neuspešno kreiranje albuma", "select_user_for_sharing_page_share_suggestions": "Sugestije", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Verzija Aplikacije", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Pregledaj sliku", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Neuspešne rezervne kopije: {}", "setting_notifications_notify_hours": "{} sati", @@ -612,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Priznati", "version_announcement_overlay_release_notes": "novine nove verzije", "version_announcement_overlay_text_1": "Ćao, nova verzija", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index 0075f65de0..6fb2ed4ff5 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "Update", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Asset Viewer", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -131,6 +137,7 @@ "backup_manual_success": "Success", "backup_manual_title": "Upload status", "backup_options_page_title": "Backup options", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cancel": "Cancel", "change_password_form_confirm_password": "Confirm Password", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "Places", "curated_object_page_title": "Things", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", "edit_location_dialog_title": "Location", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_details": "DETAILS", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_title": "Album title", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Trash", "recently_added": "Recently added", "recently_added_page_title": "Recently Added", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Suggestions", "select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App Version", "server_info_box_latest_release": "Latest Version", "server_info_box_server_url": "Server URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_title": "Images", "setting_languages_apply": "Apply", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Languages", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_hours": "{} hours", @@ -612,6 +639,8 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_text_1": "Hi friend, there is a new release of", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 76ec0e7e1d..99b612c458 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -7,6 +7,7 @@ "action_common_select": "Välj", "action_common_update": "Uppdatera", "add_a_name": "Lägg till namn", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Tillagd till {album}", "add_to_album_bottom_sheet_already_exists": "Redan i {album}", "advanced_settings_log_level_title": "Loggnivå: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} objekt har återställts", "assets_trashed": "{} objekt raderade", "assets_trashed_from_server": "{} objekt raderade från Immich-servern", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Objektvisare", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Tryck en gång för att inkludera, tryck två gånger för att exkludera", "backup_album_selection_page_assets_scatter": "Objekt kan vara utspridda över flera album. Därför kan album inkluderas eller exkluderas under säkerhetskopieringsprocessen", @@ -131,6 +137,7 @@ "backup_manual_success": "Klart", "backup_manual_title": "Uppladdningsstatus", "backup_options_page_title": "Säkerhetskopieringsinställningar", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Miniatyrbilder för bibliotek ({} bilder och videor)", "cache_settings_clear_cache_button": "Rensa cacheminnet", "cache_settings_clear_cache_button_title": "Rensar appens cacheminne. Detta kommer att avsevärt påverka appens prestanda tills cachen har byggts om.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kontrollera beteende för lokal lagring", "cache_settings_tile_title": "Lokal Lagring", "cache_settings_title": "Cache Inställningar", + "cancel": "Cancel", "change_password_form_confirm_password": "Bekräfta lösenord", "change_password_form_description": "Hej {name},\n\nDet är antingen första gången du loggar in i systemet, eller så har det skett en förfrågan om återställning av ditt lösenord. Ange ditt nya lösenord nedan.", "change_password_form_new_password": "Nytt lösenord", "change_password_form_password_mismatch": "Lösenorden matchar inte", "change_password_form_reenter_new_password": "Ange Nytt Lösenord Igen", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Kontrollera", + "check_corrupt_asset_backup_description": "Kör kontrollen endast över Wi-Fi och när alla resurser har säkerhetskopierats. Det kan ta några minuter.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Ange Lösenord", "client_cert_import": "Importera", @@ -199,6 +210,7 @@ "crop": "Beskär", "curated_location_page_title": "Platser", "curated_object_page_title": "Objekt", + "current_server_address": "Current server address", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E d. LLL y • hh:mm", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Tidszon", "edit_image_title": "Redigera", "edit_location_dialog_title": "Plats", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Fel: {}", "exif_bottom_sheet_description": "Lägg till beskrivning...", "exif_bottom_sheet_details": "DETALJER", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Aktivera experimentellt fotorutnät", "experimental_settings_subtitle": "Använd på egen risk!", "experimental_settings_title": "Experimentellt", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favoriter", "favorites_page_no_favorites": "Inga favoritobjekt hittades", "favorites_page_title": "Favoriter", "filename_search": "Filnamn eller filändelse", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Aktivera haptisk feedback", "haptic_feedback_title": "Haptisk Feedback", "header_settings_add_header_tip": "Lägg Till Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Äldsta foto", "library_page_sort_most_recent_photo": "Senaste foto", "library_page_sort_title": "Albumtitel", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Välj på karta", "location_picker_latitude": "Latitud", "location_picker_latitude_error": "Ange en giltig latitud", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Kan inte ändra datum på skrivskyddade objekt, hoppar över", "multiselect_grid_edit_gps_err_read_only": "Kan inte ändra plats på skrivskyddade objekt, hoppar över", "my_albums": "Mina album", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Inga objekt att visa", "no_name": "Inget namn", "notification_permission_dialog_cancel": "Avbryt", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Rättighet begränsad. För att låta Immich säkerhetskopiera och hantera hela ditt galleri, tillåt foto- och video-rättigheter i Inställningar.", "permission_onboarding_request": "Immich kräver tillstånd för att se dina foton och videor.", "places": "Platser", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Inställningar", "profile_drawer_app_logs": "Loggar", "profile_drawer_client_out_of_date_major": "Mobilappen är utdaterad. Uppdatera till senaste huvudversionen.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Papperskorg", "recently_added": "Nyligen tillagda", "recently_added_page_title": "Nyligen tillagda", + "save": "Save", "save_to_gallery": "Spara i galleri", "scaffold_body_error_occurred": "Fel uppstod", "search_albums": "Sök i album", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Förslag", "select_user_for_sharing_page_err_album": "Kunde inte skapa nytt album", "select_user_for_sharing_page_share_suggestions": "Förslag", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App-version", "server_info_box_latest_release": "Senaste Version", "server_info_box_server_url": "Server-URL", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Ladda förhandsgranskning av bild", "setting_image_viewer_title": "Bilder", "setting_languages_apply": "Verkställ", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Språk", "setting_notifications_notify_failures_grace_period": "Rapportera säkerhetskopieringsfel i bakgrunden: {}", "setting_notifications_notify_hours": "{} timmar", @@ -612,6 +639,8 @@ "upload_dialog_info": "Vill du säkerhetskopiera de valda objekten till servern?", "upload_dialog_ok": "Ladda Upp", "upload_dialog_title": "Ladda Upp Objekt", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Bekräfta", "version_announcement_overlay_release_notes": "versionsinformation", "version_announcement_overlay_text_1": "Hej vännen, det finns en ny version av", @@ -621,5 +650,7 @@ "videos": "Videor", "viewer_remove_from_stack": "Ta bort från Stapeln", "viewer_stack_use_as_main_asset": "Använd som Huvudobjekt", - "viewer_unstack": "Stapla Av" + "viewer_unstack": "Stapla Av", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index b6013ceed4..a825270fd7 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -7,6 +7,7 @@ "action_common_select": "Select", "action_common_update": "อัปเดต", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album}", "add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว", "advanced_settings_log_level_title": "ระดับการ Log: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "ตัวดูทรัพยากร", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "อัลบั้มบนเครื่อง ({})", "backup_album_selection_page_albums_tap": "กดเพื่อรวม กดสองครั้งเพื่อยกเว้น", "backup_album_selection_page_assets_scatter": "ทรัพยาการสามารถกระจายไปในหลายอัลบั้ม ดังนั้นอัลบั้มสามารถถูกรวมหรือยกเว้นในกระบวนการสำรองข้อมูล", @@ -131,6 +137,7 @@ "backup_manual_success": "สำเร็จ", "backup_manual_title": "สถานะอัพโหลด", "backup_options_page_title": "ตัวเลือกการสำรองข้อมูล", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "รูปย่อคลังภาพ ({} ทรัพยากร)", "cache_settings_clear_cache_button": "ล้างแคช", "cache_settings_clear_cache_button_title": "ล้างแคชของแอพ จะส่งผลกระทบต่อประสิทธิภาพแอพจนกว่าแคชจะถูกสร้างใหม่", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "ควบคุมพฤติกรรมของที่จัดเก็บในตัวเครื่อง", "cache_settings_tile_title": "ที่จัดเก็บในตัวเครื่อง", "cache_settings_title": "ตั้งค่าแคช", + "cancel": "Cancel", "change_password_form_confirm_password": "ยืนยันรหัสผ่าน", "change_password_form_description": "สวัสดี {name},\n\nครั้งนี้อาจจะเป็นครั้งแรกที่คุณเข้าสู่ระบบ หรือมีคำขอเพื่อที่จะเปลี่ยนรหัสผ่านของคุI กรุณาเพิ่มรหัสผ่านใหม่ข้างล่าง", "change_password_form_new_password": "รหัสผ่านใหม่", "change_password_form_password_mismatch": "รหัสผ่านไม่ตรงกัน", "change_password_form_reenter_new_password": "กรอกรหัสผ่านใหม่", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Enter Password", "client_cert_import": "Import", @@ -199,6 +210,7 @@ "crop": "Crop", "curated_location_page_title": "สถานที่", "curated_object_page_title": "สิ่งของ", + "current_server_address": "Current server address", "daily_title_text_date": "E dd MMM", "daily_title_text_date_year": "E dd MMM yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "เขดเวลา", "edit_image_title": "Edit", "edit_location_dialog_title": "ตำแหน่ง", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Error: {}", "exif_bottom_sheet_description": "เพิ่มคำอธิบาย", "exif_bottom_sheet_details": "รายละเอียด", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "เปิดตารางรูปภาพที่กำลังทดลอง", "experimental_settings_subtitle": "ใช้ภายใต้ความเสี่ยงของคุณเอง!", "experimental_settings_title": "ทดลอง", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "ไม่พบทรัพยากรในรายการโปรด", "favorites_page_title": "รายการโปรด", "filename_search": "File name or extension", "filter": "Filter", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "เปิดการตอบสนองแบบสัมผัส", "haptic_feedback_title": "การตอบสนองแบบสัมผัส", "header_settings_add_header_tip": "Add Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "รูปภาพที่เก่าที่สุด", "library_page_sort_most_recent_photo": "รูปล่าสุด", "library_page_sort_title": "ชื่ออัลบั้ม", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "เลือกบนแผนที่", "location_picker_latitude": "ละติจูต", "location_picker_latitude_error": "กรุณาเพิ่มละติจูตที่ถูกต้อง", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "ไม่สามารถแก้ไขวันที่ทรัพยากรแบบอ่านอย่างเดียว กำลังข้าม", "multiselect_grid_edit_gps_err_read_only": "ไม่สามารถแก้ตำแหน่งของทรัพยากรแบบอ่านอย่างเดียว กำลังข้าม", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "ไม่มีทรัพยากรให้แสดง", "no_name": "No name", "notification_permission_dialog_cancel": "ยกเลิก", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "สิทธ์จำกัด เพื่อให้ Immich สำรองข้อมูลและจัดการคลังภาพได้ ตั้งค่าสิทธิเข้าถึงรูปภาพและวิดีโอ", "permission_onboarding_request": "Immich จำเป็นจะต้องได้รับสิทธิ์ดูรูปภาพและวิดีโอ", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "การตั้งค่า", "profile_drawer_app_logs": "การบันทึก", "profile_drawer_client_out_of_date_major": "แอปพลิเคชันมีอัพเดต โปรดอัปเดตเป็นเวอร์ชันหลักล่าสุด", @@ -412,6 +436,7 @@ "profile_drawer_trash": "ขยะ", "recently_added": "Recently added", "recently_added_page_title": "เพิ่มล่าสุด", + "save": "Save", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "เกิดข้อผิดพลาด", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "ข้อเสนอแนะ", "select_user_for_sharing_page_err_album": "สร้างอัลบั้มล้มเหลว", "select_user_for_sharing_page_share_suggestions": "ข้อเสนอแนะ", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "เวอร์ชันแอพ", "server_info_box_latest_release": "เวอร์ชันล่าสุด", "server_info_box_server_url": "URL เซิร์ฟเวอร์", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "โหลดรูปภาพตัวอย่าง", "setting_image_viewer_title": "รูปภาพ", "setting_languages_apply": "บันทึก", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "ภาษา", "setting_notifications_notify_failures_grace_period": "แจ้งการสำรองข้อมูลในเบื้องหลังล้มเหลว: {}", "setting_notifications_notify_hours": "{} ชั่วโมง", @@ -612,6 +639,8 @@ "upload_dialog_info": "คุณต้องการอัพโหลดทรัพยากรดังกล่าวบนเซิร์ฟเวอร์หรือไม่?", "upload_dialog_ok": "อัปโหลด", "upload_dialog_title": "อัปโหลดทรัพยากร", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "รับทราบ", "version_announcement_overlay_release_notes": "รายงานการอัพเดท", "version_announcement_overlay_text_1": "สวัสดีเพื่อน ขณะนี้มีเวอร์ชั้นใหม่ของ", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "เอาออกจากที่ซ้อน", "viewer_stack_use_as_main_asset": "ใช้เป็นทรัพยากรหลัก", - "viewer_unstack": "หยุดซ้อน" + "viewer_unstack": "หยุดซ้อน", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 8f9b6370ec..ff782bf12c 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -7,6 +7,7 @@ "action_common_select": "Вибрати", "action_common_update": "Оновити", "add_a_name": "Додати ім'я", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Додати до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "{} елемент(и) успішно відновлено", "assets_trashed": "{} елемент(и) поміщено до кошика", "assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Переглядач зображень", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({})", "backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити", "backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.", @@ -131,6 +137,7 @@ "backup_manual_success": "Успіх", "backup_manual_title": "Стан завантаження", "backup_options_page_title": "Резервне копіювання", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)", "cache_settings_clear_cache_button": "Очистити кеш", "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Керування поведінкою локального сховища", "cache_settings_tile_title": "Локальне сховище", "cache_settings_title": "Налаштування кешування", + "cancel": "Cancel", "change_password_form_confirm_password": "Підтвердити пароль", "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", "change_password_form_new_password": "Новий пароль", "change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_reenter_new_password": "Повторіть новий пароль", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Введіть пароль", "client_cert_import": "Імпорт", @@ -199,6 +210,7 @@ "crop": "Кадрувати", "curated_location_page_title": "Місця", "curated_object_page_title": "Речі", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Часовий пояс", "edit_image_title": "Редагувати", "edit_location_dialog_title": "Місцезнаходження", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Помилка: {}", "exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_details": "ПОДРОБИЦІ", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_subtitle": "На власний ризик!", "experimental_settings_title": "Експериментальні", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Вибране", "favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_title": "Улюблені", "filename_search": "Ім'я або розширення файлу", "filter": "Фільтр", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_title": "Тактильна віддача", "header_settings_add_header_tip": "Додати заголовок", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Найдавніші фото", "library_page_sort_most_recent_photo": "Найновіші фото", "library_page_sort_title": "Назва альбому", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Обрати на мапі", "location_picker_latitude": "Широта", "location_picker_latitude_error": "Вкажіть дійсну широту", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "my_albums": "Мої альбоми", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Елементи відсутні", "no_name": "Без імені", "notification_permission_dialog_cancel": "Скасувати", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", "places": "Місця", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Параметри", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Кошик", "recently_added": "Нещодавно додані", "recently_added_page_title": "Нещодавні", + "save": "Save", "save_to_gallery": "Зберегти в галерею", "scaffold_body_error_occurred": "Виникла помилка", "search_albums": "Пошук альбому", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Пропозиції", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом", "select_user_for_sharing_page_share_suggestions": "Пропозиції", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Версія додатка", "server_info_box_latest_release": "Остання версія", "server_info_box_server_url": "URL сервера", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_image_viewer_title": "Зображення", "setting_languages_apply": "Застосувати", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Мова", "setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}", "setting_notifications_notify_hours": "{} годин", @@ -612,6 +639,8 @@ "upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?", "upload_dialog_ok": "Завантажити", "upload_dialog_title": "Завантажити Елементи", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Прийняти", "version_announcement_overlay_release_notes": "примітки до випуску", "version_announcement_overlay_text_1": "Вітаємо, є новий випуск ", @@ -621,5 +650,7 @@ "videos": "Відео", "viewer_remove_from_stack": "Видалити зі стеку", "viewer_stack_use_as_main_asset": "Використовувати як основний елементи", - "viewer_unstack": "Розібрати стек" + "viewer_unstack": "Розібрати стек", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 0a26e6dd70..ba42c63655 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -7,6 +7,7 @@ "action_common_select": "Chọn", "action_common_update": "Cập nhật", "add_a_name": "Add a name", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "Thêm vào {album}", "add_to_album_bottom_sheet_already_exists": "Đã có sẵn trong {album}", "advanced_settings_log_level_title": "Phân loại nhật ký: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "Đã khôi phục {} mục thành công", "assets_trashed": "Đã chuyển {} mục vào thùng rác", "assets_trashed_from_server": "Đã chuyển {} mục từ máy chủ Immich vào thùng rác", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "Trình xem ảnh", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "Album trên thiết bị ({})", "backup_album_selection_page_albums_tap": "Nhấn để chọn, nhấn đúp để bỏ qua", "backup_album_selection_page_assets_scatter": "Ảnh có thể có trong nhiều album khác nhau. Trong quá trình sao lưu, bạn có thể chọn để sao lưu tất cả các album hoặc chỉ một số album nhất định.", @@ -121,7 +127,7 @@ "backup_controller_page_total": "Tổng số", "backup_controller_page_total_sub": "Tất cả ảnh và video không trùng lập từ các album được chọn", "backup_controller_page_turn_off": "Tắt sao lưu khi ứng dụng hoạt động", - "backup_controller_page_turn_on": "Bật sao lưu khi ứng dụng hoạt động", + "backup_controller_page_turn_on": "Bật sao lưu khi mở ứng dụng", "backup_controller_page_uploading_file_info": "Thông tin tệp đang tải lên", "backup_err_only_album": "Không thể xóa album duy nhất", "backup_info_card_assets": "ảnh", @@ -131,6 +137,7 @@ "backup_manual_success": "Thành công", "backup_manual_title": "Trạng thái tải lên", "backup_options_page_title": "Tuỳ chỉnh sao lưu", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "Trang thư viện hình thu nhỏ ({} ảnh)", "cache_settings_clear_cache_button": "Xoá bộ nhớ đệm", "cache_settings_clear_cache_button_title": "Xóa bộ nhớ đệm của ứng dụng. Điều này sẽ ảnh hưởng đến hiệu suất của ứng dụng đến khi bộ nhớ đệm được tạo lại.", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "Kiểm soát cách xử lý lưu trữ cục bộ", "cache_settings_tile_title": "Lưu trữ cục bộ", "cache_settings_title": "Cài đặt bộ nhớ đệm", + "cancel": "Cancel", "change_password_form_confirm_password": "Xác nhận mật khẩu", "change_password_form_description": "Xin chào {name},\n\nĐây là lần đầu tiên bạn đăng nhập vào hệ thống hoặc đã có yêu cầu thay đổi mật khẩu. Vui lòng nhập mật khẩu mới bên dưới.", "change_password_form_new_password": "Mật khẩu mới", "change_password_form_password_mismatch": "Mật khẩu không giống nhau", "change_password_form_reenter_new_password": "Nhập lại mật khẩu mới", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "Đồng ý", "client_cert_enter_password": "Nhập mật khẩu", "client_cert_import": "Nhập", @@ -199,6 +210,7 @@ "crop": "Cắt", "curated_location_page_title": "Địa điểm", "curated_object_page_title": "Sự vật", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "Múi giờ", "edit_image_title": "Sửa", "edit_location_dialog_title": "Vị trí", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "Lỗi: {}", "exif_bottom_sheet_description": "Thêm mô tả...", "exif_bottom_sheet_details": "CHI TIẾT", @@ -246,13 +259,17 @@ "experimental_settings_new_asset_list_title": "Bật lưới ảnh thử nghiệm", "experimental_settings_subtitle": "Sử dụng có thể rủi ro!", "experimental_settings_title": "Chưa hoàn thiện", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "Favorites", "favorites_page_no_favorites": "Không tìm thấy ảnh yêu thích", "favorites_page_title": "Ảnh yêu thích", "filename_search": "Tên hoặc phần mở rộng tập tin", "filter": "Bộ lọc", - "haptic_feedback_switch": "Bật phản hồi haptic\n", - "haptic_feedback_title": "Haptic Feedback\n", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", + "haptic_feedback_switch": "Bật phản hồi haptic", + "haptic_feedback_title": "Phản hồi Hapic", "header_settings_add_header_tip": "Thêm Header", "header_settings_field_validator_msg": "Trường này không được để trống", "header_settings_header_name_input": "Tên Header", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "Ảnh cũ nhất", "library_page_sort_most_recent_photo": "Ảnh gần đây nhất", "library_page_sort_title": "Tiêu đề album", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "Chọn trên bản đồ", "location_picker_latitude": "Vĩ độ", "location_picker_latitude_error": "Nhập vĩ độ hợp lệ", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "Không thể chỉnh sửa ngày của ảnh chỉ có quyền đọc, bỏ qua", "multiselect_grid_edit_gps_err_read_only": "Không thể chỉnh sửa vị trí của ảnh chỉ có quyền đọc, bỏ qua", "my_albums": "My albums", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "Không có mục nào để hiển thị", "no_name": "Không có tên", "notification_permission_dialog_cancel": "Từ chối", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "Quyền truy cập vào ảnh của bạn bị hạn chế. Để Immich sao lưu và quản lý toàn bộ thư viện ảnh của bạn, hãy cấp quyền truy cập toàn bộ ảnh trong Cài đặt.", "permission_onboarding_request": "Immich cần quyền để xem ảnh và video của bạn", "places": "Places", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Tuỳ chỉnh", "profile_drawer_app_logs": "Nhật ký", "profile_drawer_client_out_of_date_major": "Ứng dụng đã lỗi thời. Vui lòng cập nhật lên phiên bản chính mới nhất.", @@ -412,6 +436,7 @@ "profile_drawer_trash": "Thùng rác", "recently_added": "Recently added", "recently_added_page_title": "Mới thêm gần đây", + "save": "Save", "save_to_gallery": "Lưu vào thư viện", "scaffold_body_error_occurred": "Xảy ra lỗi", "search_albums": "Search albums", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "Gợi ý", "select_user_for_sharing_page_err_album": "Tạo album thất bại", "select_user_for_sharing_page_share_suggestions": "Gợi ý", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "Phiên bản ứng dụng", "server_info_box_latest_release": "Phiên bản mới nhất", "server_info_box_server_url": "Địa chỉ máy chủ", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "Tải ảnh xem trước", "setting_image_viewer_title": "Hình ảnh", "setting_languages_apply": "Áp dụng", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "Ngôn ngữ", "setting_notifications_notify_failures_grace_period": "Thông báo sao lưu nền thất bại: {}", "setting_notifications_notify_hours": "{} giờ", @@ -612,6 +639,8 @@ "upload_dialog_info": "Bạn có muốn sao lưu những mục đã chọn tới máy chủ không?", "upload_dialog_ok": "Tải lên", "upload_dialog_title": "Tải lên ảnh", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "Công nhận", "version_announcement_overlay_release_notes": "ghi chú phát hành", "version_announcement_overlay_text_1": "Chào bạn, có một bản phát hành mới của", @@ -621,5 +650,7 @@ "videos": "Videos", "viewer_remove_from_stack": "Xoá khỏi nhóm", "viewer_stack_use_as_main_asset": "Đặt làm lựa chọn hàng đầu", - "viewer_unstack": "Huỷ xếp nhóm" + "viewer_unstack": "Huỷ xếp nhóm", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 0da7c3b2db..1b8dd21582 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -7,6 +7,7 @@ "action_common_select": "选择", "action_common_update": "更新", "add_a_name": "添加姓名", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "添加到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", "advanced_settings_log_level_title": "日志等级:{}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "已成功恢复{}个项目", "assets_trashed": "{}个回收站项目", "assets_trashed_from_server": "{}个项目已放入回收站", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "资源查看器", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "设备上的相册({})", "backup_album_selection_page_albums_tap": "单击选中,双击取消", "backup_album_selection_page_assets_scatter": "项目会分散在多个相册中。因此,可以在备份过程中包含或排除相册。", @@ -131,6 +137,7 @@ "backup_manual_success": "成功", "backup_manual_title": "上传状态", "backup_options_page_title": "备份选项", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "图库缩略图({} 项)", "cache_settings_clear_cache_button": "清除缓存", "cache_settings_clear_cache_button_title": "清除应用缓存。在重新生成缓存之前,将显著影响应用的性能。", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "设置本地存储行为", "cache_settings_tile_title": "本地存储", "cache_settings_title": "缓存设置", + "cancel": "Cancel", "change_password_form_confirm_password": "确认密码", "change_password_form_description": "{name} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", "change_password_form_new_password": "新密码", "change_password_form_password_mismatch": "密码不匹配", "change_password_form_reenter_new_password": "再次输入新密码", + "check_corrupt_asset_backup": "检查备份是否损坏", + "check_corrupt_asset_backup_button": "执行检查", + "check_corrupt_asset_backup_description": "仅在连接到Wi-Fi并完成所有项目备份后执行此检查。该过程可能需要几分钟。", "client_cert_dialog_msg_confirm": "确定", "client_cert_enter_password": "输入密码", "client_cert_import": "导入", @@ -199,6 +210,7 @@ "crop": "裁剪", "curated_location_page_title": "地点", "curated_object_page_title": "事物", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", "edit_location_dialog_title": "位置", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "错误:{}", "exif_bottom_sheet_description": "添加描述...", "exif_bottom_sheet_details": "详情", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", "experimental_settings_title": "实验性功能", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "收藏", "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", "filename_search": "文件名或扩展名", "filter": "筛选", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "启用振动反馈", "haptic_feedback_title": "振动反馈", "header_settings_add_header_tip": "添加标头", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "最早的照片", "library_page_sort_most_recent_photo": "最近的项目", "library_page_sort_title": "相册标题", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "在地图上选择", "location_picker_latitude": "纬度", "location_picker_latitude_error": "输入有效的纬度值", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", "multiselect_grid_edit_gps_err_read_only": "无法编辑只读项目的位置信息,跳过", "my_albums": "我的相册", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "无项目展示", "no_name": "无姓名", "notification_permission_dialog_cancel": "取消", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "权限受限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", "places": "地点", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "偏好设置", "profile_drawer_app_logs": "日志", "profile_drawer_client_out_of_date_major": "客户端有大版本升级,请尽快升级至最新版。", @@ -412,6 +436,7 @@ "profile_drawer_trash": "回收站", "recently_added": "近期添加", "recently_added_page_title": "最近添加", + "save": "Save", "save_to_gallery": "保存到图库", "scaffold_body_error_occurred": "发生错误", "search_albums": "搜索相册", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "建议", "select_user_for_sharing_page_err_album": "创建相册失败", "select_user_for_sharing_page_share_suggestions": "建议", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App 版本", "server_info_box_latest_release": "最新版本", "server_info_box_server_url": "服务器地址", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "加载预览图", "setting_image_viewer_title": "图片", "setting_languages_apply": "应用", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "语言", "setting_notifications_notify_failures_grace_period": "后台备份失败通知:{}", "setting_notifications_notify_hours": "{} 小时", @@ -612,6 +639,8 @@ "upload_dialog_info": "是否要将所选项目备份到服务器?", "upload_dialog_ok": "上传", "upload_dialog_title": "上传项目", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "我知道了", "version_announcement_overlay_release_notes": "发行说明", "version_announcement_overlay_text_1": "号外号外,有新版本的", @@ -621,5 +650,7 @@ "videos": "视频", "viewer_remove_from_stack": "从堆叠中移除", "viewer_stack_use_as_main_asset": "作为主项目使用", - "viewer_unstack": "取消堆叠" + "viewer_unstack": "取消堆叠", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index 21a7fc2e4e..93b1d8ce55 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -7,6 +7,7 @@ "action_common_select": "选择", "action_common_update": "更新", "add_a_name": "添加姓名", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "添加到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", "advanced_settings_log_level_title": "日志等级:{}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "已成功恢复{}个项目", "assets_trashed": "{}个回收站项目", "assets_trashed_from_server": "{}个项目已放入回收站", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "资源查看器", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "设备上的相册({})", "backup_album_selection_page_albums_tap": "单击选中,双击取消", "backup_album_selection_page_assets_scatter": "项目会分散在多个相册中。因此,可以在备份过程中包含或排除相册。", @@ -131,6 +137,7 @@ "backup_manual_success": "成功", "backup_manual_title": "上传状态", "backup_options_page_title": "备份选项", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "图库缩略图({} 项)", "cache_settings_clear_cache_button": "清除缓存", "cache_settings_clear_cache_button_title": "清除应用缓存。在重新生成缓存之前,将显著影响应用的性能。", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "设置本地存储行为", "cache_settings_tile_title": "本地存储", "cache_settings_title": "缓存设置", + "cancel": "Cancel", "change_password_form_confirm_password": "确认密码", "change_password_form_description": "{name} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", "change_password_form_new_password": "新密码", "change_password_form_password_mismatch": "密码不匹配", "change_password_form_reenter_new_password": "再次输入新密码", + "check_corrupt_asset_backup": "检查备份是否损坏", + "check_corrupt_asset_backup_button": "执行检查", + "check_corrupt_asset_backup_description": "仅在连接到Wi-Fi并完成所有项目备份后执行此检查。该过程可能需要几分钟。", "client_cert_dialog_msg_confirm": "确定", "client_cert_enter_password": "输入密码", "client_cert_import": "导入", @@ -199,6 +210,7 @@ "crop": "裁剪", "curated_location_page_title": "地点", "curated_object_page_title": "事物", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", "edit_location_dialog_title": "位置", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "错误:{}", "exif_bottom_sheet_description": "添加描述...", "exif_bottom_sheet_details": "详情", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", "experimental_settings_title": "实验性功能", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "收藏", "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", "filename_search": "文件名或扩展名", "filter": "筛选", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "启用振动反馈", "haptic_feedback_title": "振动反馈", "header_settings_add_header_tip": "添加标头", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "最早的照片", "library_page_sort_most_recent_photo": "最近的项目", "library_page_sort_title": "相册标题", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "在地图上选择", "location_picker_latitude": "纬度", "location_picker_latitude_error": "输入有效的纬度值", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", "multiselect_grid_edit_gps_err_read_only": "无法编辑只读项目的位置信息,跳过", "my_albums": "我的相册", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "无项目展示", "no_name": "无姓名", "notification_permission_dialog_cancel": "取消", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "权限受限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", "places": "地点", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "偏好设置", "profile_drawer_app_logs": "日志", "profile_drawer_client_out_of_date_major": "客户端有大版本升级,请尽快升级至最新版。", @@ -412,6 +436,7 @@ "profile_drawer_trash": "回收站", "recently_added": "近期添加", "recently_added_page_title": "最近添加", + "save": "Save", "save_to_gallery": "保存到图库", "scaffold_body_error_occurred": "发生错误", "search_albums": "搜索相册", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "建议", "select_user_for_sharing_page_err_album": "创建相册失败", "select_user_for_sharing_page_share_suggestions": "建议", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App 版本", "server_info_box_latest_release": "最新版本", "server_info_box_server_url": "服务器地址", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "加载预览图", "setting_image_viewer_title": "图片", "setting_languages_apply": "应用", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "语言", "setting_notifications_notify_failures_grace_period": "后台备份失败通知:{}", "setting_notifications_notify_hours": "{} 小时", @@ -612,6 +639,8 @@ "upload_dialog_info": "是否要将所选项目备份到服务器?", "upload_dialog_ok": "上传", "upload_dialog_title": "上传项目", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "我知道了", "version_announcement_overlay_release_notes": "发行说明", "version_announcement_overlay_text_1": "号外号外,有新版本的", @@ -621,5 +650,7 @@ "videos": "视频", "viewer_remove_from_stack": "从堆叠中移除", "viewer_stack_use_as_main_asset": "作为主项目使用", - "viewer_unstack": "取消堆叠" + "viewer_unstack": "取消堆叠", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index 9fd3a19e5e..84120c50f7 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -7,6 +7,7 @@ "action_common_select": "選擇", "action_common_update": "更新", "add_a_name": "新增姓名", + "add_endpoint": "Add endpoint", "add_to_album_bottom_sheet_added": "新增到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", "advanced_settings_log_level_title": "日誌等級: {}", @@ -65,7 +66,12 @@ "assets_restored_successfully": "已成功恢復 {} 個項目", "assets_trashed": "{} 個回收桶項目", "assets_trashed_from_server": "{} 個項目已放入回收桶", + "asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_title": "資源查看器", + "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", + "automatic_endpoint_switching_title": "Automatic URL switching", + "background_location_permission": "Background location permission", + "background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "backup_album_selection_page_albums_device": "裝置上的相簿( {} )", "backup_album_selection_page_albums_tap": "單擊選中,雙擊取消", "backup_album_selection_page_assets_scatter": "項目會分散在多個相簿中。因此,可以在備份過程中包含或排除相簿。", @@ -131,6 +137,7 @@ "backup_manual_success": "成功", "backup_manual_title": "上傳狀態", "backup_options_page_title": "備份選項", + "backup_setting_subtitle": "Manage background and foreground upload settings", "cache_settings_album_thumbnails": "圖庫縮圖( {} 項)", "cache_settings_clear_cache_button": "清除緩存", "cache_settings_clear_cache_button_title": "清除套用緩存。在重新生成緩存之前,將顯著影響套用的性能。", @@ -149,11 +156,15 @@ "cache_settings_tile_subtitle": "設定本地存儲行為", "cache_settings_tile_title": "本地存儲", "cache_settings_title": "緩存設定", + "cancel": "Cancel", "change_password_form_confirm_password": "確認密碼", "change_password_form_description": "您好 {name} :\n\n這是您首次登入系統,或被管理員要求更改密碼。\n請在下方輸入新密碼。", "change_password_form_new_password": "新密碼", "change_password_form_password_mismatch": "密碼不一致", "change_password_form_reenter_new_password": "再次輸入新密碼", + "check_corrupt_asset_backup": "Check for corrupt asset backups", + "check_corrupt_asset_backup_button": "Perform check", + "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "client_cert_dialog_msg_confirm": "確定", "client_cert_enter_password": "輸入密碼", "client_cert_import": "匯入", @@ -199,6 +210,7 @@ "crop": "裁剪", "curated_location_page_title": "地點", "curated_object_page_title": "事物", + "current_server_address": "Current server address", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", @@ -235,6 +247,7 @@ "edit_date_time_dialog_timezone": "時區", "edit_image_title": "編輯", "edit_location_dialog_title": "位置", + "enter_wifi_name": "Enter WiFi name", "error_saving_image": "錯誤: {} ", "exif_bottom_sheet_description": "新增描述...", "exif_bottom_sheet_details": "詳情", @@ -246,11 +259,15 @@ "experimental_settings_new_asset_list_title": "啓用實驗性照片網格", "experimental_settings_subtitle": "使用風險自負!", "experimental_settings_title": "實驗性功能", + "external_network": "External network", + "external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "favorites": "收藏", "favorites_page_no_favorites": "未找到收藏項目", "favorites_page_title": "收藏", "filename_search": "文件名或副檔名", "filter": "篩選", + "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", + "grant_permission": "Grant permission", "haptic_feedback_switch": "啓用振動反饋", "haptic_feedback_title": "振動反饋", "header_settings_add_header_tip": "新增標頭", @@ -296,6 +313,10 @@ "library_page_sort_most_oldest_photo": "最早的照片", "library_page_sort_most_recent_photo": "最近的項目", "library_page_sort_title": "相簿標題", + "local_network": "Local network", + "local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", + "location_permission": "Location permission", + "location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_picker_choose_on_map": "在地圖上選擇", "location_picker_latitude": "緯度", "location_picker_latitude_error": "輸入有效的緯度值", @@ -365,6 +386,8 @@ "multiselect_grid_edit_date_time_err_read_only": "無法編輯唯讀項目的日期,略過", "multiselect_grid_edit_gps_err_read_only": "無法編輯唯讀項目的位置資訊,略過", "my_albums": "我的相簿", + "networking_settings": "Networking", + "networking_subtitle": "Manage the server endpoint settings", "no_assets_to_show": "無項目展示", "no_name": "無姓名", "notification_permission_dialog_cancel": "取消", @@ -398,6 +421,7 @@ "permission_onboarding_permission_limited": "權限受限:要讓 Immich 備份和管理您的整個圖庫收藏,請在「設定」中授予照片和短片權限。", "permission_onboarding_request": "Immich 需要權限才能查看您的照片和短片。", "places": "地點", + "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "偏好設定", "profile_drawer_app_logs": "日誌", "profile_drawer_client_out_of_date_major": "客戶端有大版本升級,請盡快升級至最新版。", @@ -412,6 +436,7 @@ "profile_drawer_trash": "回收桶", "recently_added": "近期新增", "recently_added_page_title": "最近新增", + "save": "Save", "save_to_gallery": "儲存到圖庫", "scaffold_body_error_occurred": "發生錯誤", "search_albums": "搜尋相簿", @@ -469,6 +494,7 @@ "select_additional_user_for_sharing_page_suggestions": "建議", "select_user_for_sharing_page_err_album": "新增相簿失敗", "select_user_for_sharing_page_share_suggestions": "建議", + "server_endpoint": "Server Endpoint", "server_info_box_app_version": "App 版本", "server_info_box_latest_release": "最新版本", "server_info_box_server_url": "伺服器地址", @@ -480,6 +506,7 @@ "setting_image_viewer_preview_title": "載入預覽圖", "setting_image_viewer_title": "圖片", "setting_languages_apply": "套用", + "setting_languages_subtitle": "Change the app's language", "setting_languages_title": "語言", "setting_notifications_notify_failures_grace_period": "背景備份失敗通知: {} ", "setting_notifications_notify_hours": " {} 小時", @@ -612,6 +639,8 @@ "upload_dialog_info": "是否要將所選項目備份到伺服器?", "upload_dialog_ok": "上傳", "upload_dialog_title": "上傳項目", + "use_current_connection": "use current connection", + "validate_endpoint_error": "Please enter a valid URL", "version_announcement_overlay_ack": "我知道了", "version_announcement_overlay_release_notes": "發行說明", "version_announcement_overlay_text_1": "好消息,有新版本的", @@ -621,5 +650,7 @@ "videos": "短片", "viewer_remove_from_stack": "從堆疊中移除", "viewer_stack_use_as_main_asset": "作為主項目使用", - "viewer_unstack": "取消堆疊" + "viewer_unstack": "取消堆疊", + "wifi_name": "WiFi Name", + "your_wifi_name": "Your WiFi name" } \ No newline at end of file From 37220a342a9a3cde67bedfc7a791a6660fa145e1 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:29:46 +0000 Subject: [PATCH 515/599] chore: version v1.122.0 --- cli/package-lock.json | 8 ++++---- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 16 ++++++++-------- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/openapi/devtools_options.yaml | 3 --- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 19 files changed, 36 insertions(+), 35 deletions(-) delete mode 100644 mobile/openapi/devtools_options.yaml diff --git a/cli/package-lock.json b/cli/package-lock.json index 8bb364ee23..7681417b76 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.32", + "version": "2.2.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.32", + "version": "2.2.33", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -31,7 +31,7 @@ "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "9.14.0", + "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f0ab3aedc1..ab66ac3118 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.32", + "version": "2.2.33", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 32f14e8639..049f79dba6 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.122.0", + "url": "https://v1.122.0.archive.immich.app" + }, { "label": "v1.121.0", "url": "https://v1.121.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 90750b5ed3..50bd5c6ce3 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.121.0", + "version": "1.122.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -23,7 +23,7 @@ "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "9.14.0", + "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.32", + "version": "2.2.33", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -65,13 +65,13 @@ "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", "@types/node": "^22.9.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", "@vitest/coverage-v8": "^2.0.5", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^9.0.0", + "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index a030ecdb1b..9727fbd50d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.121.0", + "version": "1.122.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index cfb55bdc6b..d6baffcef5 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.121.0" +version = "1.122.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 4c62a45ad7..7e2ea4c8e8 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 168, - "android.injected.version.name" => "1.121.0", + "android.injected.version.code" => 169, + "android.injected.version.name" => "1.122.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 4a48435103..9f1a78fcd7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.121.0" + version_number: "1.122.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b97ff5411c..6d881e6d3d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.121.0 +- API version: 1.122.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml deleted file mode 100644 index fa0b357c4f..0000000000 --- a/mobile/openapi/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e8bee37653..863d1c1a75 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.121.0+168 +version: 1.122.0+169 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 43985cae81..16e9c93b32 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7436,7 +7436,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.121.0", + "version": "1.122.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 7239a3c507..f8a0448799 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index eedea811a4..efcd085424 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 20d0c5715f..61f56f4404 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.121.0 + * 1.122.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 6f0b2998e8..a7eb1f50c9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.121.0", + "version": "1.122.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 9332217c00..28b0a44289 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.121.0", + "version": "1.122.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 15edeb0c28..615b17de53 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.121.0", + "version": "1.122.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 4f0062fe15..93b00cde60 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.121.0", + "version": "1.122.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 4380ecf7bbd16d8424e1ae004357fa03d09ca7b8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Dec 2024 14:10:08 -0600 Subject: [PATCH 516/599] fix(web): misaligned icon on Firefox (#14500) --- .../shared-components/side-bar/side-bar-link.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index 4da73b6288..c6e430fe99 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -36,7 +36,7 @@ }); - +

    {#if hasDropdown}
    + {#if hasDropdown && dropdownOpen} {@render hasDropdown?.()} {/if} From d36477381ab958634865eba1fc121588d0598360 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Dec 2024 19:31:27 -0500 Subject: [PATCH 517/599] chore(deps): update dependency @sveltejs/kit to v2.8.3 [security] (#14342) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 615b17de53..40a1378fa6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1965,9 +1965,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.8.1.tgz", - "integrity": "sha512-uuOfFwZ4xvnfPsiTB6a4H1ljjTUksGhWnYq5X/Y9z4x5+3uM2Md8q/YVeHL+7w+mygAwoEFdgKZ8YkUuk+VKww==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.8.3.tgz", + "integrity": "sha512-DVBVwugfzzn0SxKA+eAmKqcZ7aHZROCHxH7/pyrOi+HLtQ721eEsctGb9MkhEuqj6q/9S/OFYdn37vdxzFPdvw==", "dev": true, "hasInstallScript": true, "license": "MIT", From 07096bdcee0c8979e3930caaefb4c8be47b87593 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:43:58 -0500 Subject: [PATCH 518/599] fix(server): images with non-ascii names failing to load (#14512) * utf-8 filename * Update file.ts Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- server/src/utils/file.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index ba487840e5..869e4d7876 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -58,7 +58,7 @@ export const sendFile = async ( res.header('Content-Type', file.contentType); if (file.fileName) { - res.header('Content-Disposition', `inline; filename="${file.fileName}"`); + res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`); } const options: SendFileOptions = { dotfiles: 'allow' }; From 97c1eb72897c34bd00891d1d336504ddddebb857 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:49:14 +0000 Subject: [PATCH 519/599] chore: version v1.122.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 7681417b76..b0611f1af0 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.33", + "version": "2.2.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.33", + "version": "2.2.34", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index ab66ac3118..b69a36cf19 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.33", + "version": "2.2.34", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 049f79dba6..0d664e1272 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.122.1", + "url": "https://v1.122.1.archive.immich.app" + }, { "label": "v1.122.0", "url": "https://v1.122.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 50bd5c6ce3..d962cb3368 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.122.0", + "version": "1.122.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.33", + "version": "2.2.34", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 9727fbd50d..42ea62d64b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.122.0", + "version": "1.122.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index d6baffcef5..e4d1d7fbf5 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.122.0" +version = "1.122.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 7e2ea4c8e8..9e384e8591 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 169, - "android.injected.version.name" => "1.122.0", + "android.injected.version.name" => "1.122.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 9f1a78fcd7..1c28c050aa 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.122.0" + version_number: "1.122.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6d881e6d3d..7cfd48d83d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.122.0 +- API version: 1.122.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 863d1c1a75..6974c560a2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.122.0+169 +version: 1.122.1+169 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 16e9c93b32..a13fb6e696 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7436,7 +7436,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.122.0", + "version": "1.122.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index f8a0448799..2e69c4cfc6 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index efcd085424..bc3a3023e4 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 61f56f4404..ef82b82954 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.122.0 + * 1.122.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index a7eb1f50c9..3a01c83fa1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.122.0", + "version": "1.122.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 28b0a44289..84cd0e4aae 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.122.0", + "version": "1.122.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 40a1378fa6..4669730ae1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.122.0", + "version": "1.122.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 93b00cde60..6bac30054a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.122.0", + "version": "1.122.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From c02e3e2a2ed15534454a520c28728a0a3303262c Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Dec 2024 20:04:02 -0600 Subject: [PATCH 520/599] chore(mobile): post release tasks (#14520) --- mobile/ios/Runner/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 4389b39114..e85afdc852 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,7 +58,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.121.0 + 1.122.0 CFBundleSignature ???? CFBundleVersion From e2b36476e77af2eeec98bebb46cdba33d48ca952 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 21:10:47 -0500 Subject: [PATCH 521/599] chore(deps): update grafana/grafana docker tag to v11.3.1 (#14476) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 8d80003ee4..704f3bdfc8 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -103,7 +103,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.3.0-ubuntu@sha256:51587e148ac0214d7938e7f3fe8512182e4eb6141892a3ffb88bba1901b49285 + image: grafana/grafana:11.3.1-ubuntu@sha256:7ca40d20250157abd70a907a93617a70c9b0ad9d7e59e8e6b5c8140781350d6a volumes: - grafana-data:/var/lib/grafana From 5e955a1b030a50ebe5c4dd8ae374729c643ce678 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:24:00 +0100 Subject: [PATCH 522/599] fix(web): recent albums sort (#14545) --- .../side-bar/recent-albums.spec.ts | 28 +++++++++++++++++++ .../side-bar/recent-albums.svelte | 4 +-- 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/shared-components/side-bar/recent-albums.spec.ts diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.spec.ts b/web/src/lib/components/shared-components/side-bar/recent-albums.spec.ts new file mode 100644 index 0000000000..d5c197c003 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.spec.ts @@ -0,0 +1,28 @@ +import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte'; +import { albumFactory } from '@test-data/factories/album-factory'; +import { render, screen } from '@testing-library/svelte'; +import { tick } from 'svelte'; + +describe('RecentAlbums component', () => { + it('sorts albums by most recently updated', async () => { + const albums = [ + albumFactory.build({ updatedAt: '2024-01-01T00:00:00Z' }), + albumFactory.build({ updatedAt: '2024-01-09T00:00:01Z' }), + albumFactory.build({ updatedAt: '2024-01-10T00:00:00Z' }), + albumFactory.build({ updatedAt: '2024-01-09T00:00:00Z' }), + ]; + + sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]); + render(RecentAlbums); + + expect(sdkMock.getAllAlbums).toBeCalledTimes(1); + await tick(); + + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(3); + expect(links[0]).toHaveAttribute('href', `/albums/${albums[2].id}`); + expect(links[1]).toHaveAttribute('href', `/albums/${albums[1].id}`); + expect(links[2]).toHaveAttribute('href', `/albums/${albums[3].id}`); + }); +}); diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte index a412d5cc42..d90d7dec01 100644 --- a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte @@ -10,9 +10,7 @@ onMount(async () => { try { const allAlbums = await getAllAlbums({}); - albums = allAlbums - .sort((album1, album2) => (album1.lastModifiedAssetTimestamp! > album2.lastModifiedAssetTimestamp! ? 1 : 0)) - .slice(0, 3); + albums = allAlbums.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)).slice(0, 3); } catch (error) { handleError(error, $t('failed_to_load_assets')); } From e99edc47b7a6298f62bad7f3d12389b51472ca02 Mon Sep 17 00:00:00 2001 From: Cotterman-b <119392287+Cotterman-b@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:46:19 -0600 Subject: [PATCH 523/599] fix(mobile): fix translations on search page (#14533) * Update en-US.json * Update search.page.dart --- mobile/assets/i18n/en-US.json | 3 ++- mobile/lib/pages/search/search.page.dart | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 6fb2ed4ff5..46e9758d85 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -482,6 +482,7 @@ "search_page_places": "Places", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", + "search_page_search_photos_videos": "Search for your photos and videos", "search_page_selfies": "Selfies", "search_page_things": "Things", "search_page_videos": "Videos", @@ -653,4 +654,4 @@ "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", "your_wifi_name": "Your WiFi name" -} \ No newline at end of file +} diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 9f2ddee446..01119485cf 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -680,7 +680,7 @@ class SearchEmptyContent extends StatelessWidget { const SizedBox(height: 16), Center( child: Text( - "Search for your photos and videos", + 'search_page_search_photos_videos'.tr(), style: context.textTheme.labelLarge, ), ), From 04b311bd93e9d60e965ab4d3c5bb587bb62ef3bd Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 8 Dec 2024 17:22:39 -0600 Subject: [PATCH 524/599] chore(mobile): disable Impeller (#14589) --- mobile/android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index bbc562c103..e49cf5b8da 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ + android:value="false" /> Date: Sun, 8 Dec 2024 23:41:22 +0000 Subject: [PATCH 525/599] chore: version v1.122.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index b0611f1af0..03b8061efb 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.34", + "version": "2.2.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.34", + "version": "2.2.35", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index b69a36cf19..b58825b2b9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.34", + "version": "2.2.35", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 0d664e1272..7ba9125c03 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.122.2", + "url": "https://v1.122.2.archive.immich.app" + }, { "label": "v1.122.1", "url": "https://v1.122.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index d962cb3368..011e6b2fdd 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.122.1", + "version": "1.122.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.34", + "version": "2.2.35", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 42ea62d64b..a47b4bbae9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.122.1", + "version": "1.122.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e4d1d7fbf5..cff2a432b3 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.122.1" +version = "1.122.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 9e384e8591..4fc00ce6c7 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 169, - "android.injected.version.name" => "1.122.1", + "android.injected.version.code" => 170, + "android.injected.version.name" => "1.122.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1c28c050aa..d7604b4283 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.122.1" + version_number: "1.122.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7cfd48d83d..d9e10bc316 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.122.1 +- API version: 1.122.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6974c560a2..39621a953e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.122.1+169 +version: 1.122.2+170 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a13fb6e696..706d6a28ee 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7436,7 +7436,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.122.1", + "version": "1.122.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 2e69c4cfc6..c0d5d329d1 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index bc3a3023e4..22f480e68d 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ef82b82954..68f44d7bed 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.122.1 + * 1.122.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 3a01c83fa1..4ad00c90f7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.122.1", + "version": "1.122.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 84cd0e4aae..a7005deafa 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.122.1", + "version": "1.122.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 4669730ae1..cab21cd4dc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.122.1", + "version": "1.122.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 6bac30054a..2bd429cc1a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.122.1", + "version": "1.122.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From e4b76e8efea5cddd20f2527316b939ed61fa0087 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 9 Dec 2024 00:52:10 +0100 Subject: [PATCH 526/599] chore: add language requests from weblate (#14578) --- i18n/bn.json | 1 + i18n/ur.json | 1 + web/src/lib/constants.ts | 2 ++ 3 files changed, 4 insertions(+) create mode 100644 i18n/bn.json create mode 100644 i18n/ur.json diff --git a/i18n/bn.json b/i18n/bn.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/i18n/bn.json @@ -0,0 +1 @@ +{} diff --git a/i18n/ur.json b/i18n/ur.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/i18n/ur.json @@ -0,0 +1 @@ +{} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 8d4fb809a5..b7ea2cfb52 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -265,6 +265,7 @@ export const langs = [ { name: 'Belarusian', code: 'be', loader: () => import('$i18n/be.json') }, { name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') }, { name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') }, + { name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') }, { name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') }, { name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') }, { name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') }, @@ -319,6 +320,7 @@ export const langs = [ { name: 'Thai', code: 'th', loader: () => import('$i18n/th.json') }, { name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') }, { name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') }, + { name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json') }, { name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') }, { name: 'Chinese (Traditional)', From 1ba622adc95d7e0845e7bf7fc8a77103d73e8c5b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 8 Dec 2024 21:35:23 -0500 Subject: [PATCH 527/599] feat: Add support for vob (#14590) Add support for vob --- server/src/services/asset-media.service.spec.ts | 2 +- server/src/utils/mime-types.spec.ts | 1 + server/src/utils/mime-types.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index da7e23be54..1daeb99d0b 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -98,7 +98,7 @@ const validImages = [ '.x3f', ]; -const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv']; +const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.vob', '.webm', '.wmv']; const uploadTests = [ { diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 50fe760a04..05cd8566c8 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -92,6 +92,7 @@ describe('mimeTypes', () => { { mimetype: 'video/x-matroska', extension: '.mkv' }, { mimetype: 'video/x-ms-wmv', extension: '.wmv' }, { mimetype: 'video/x-msvideo', extension: '.avi' }, + { mimetype: 'video/mpeg', extension: '.vob' }, ]) { it(`should map ${extension} to ${mimetype}`, () => { expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index cbf6e5b489..165eb44a4f 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -74,6 +74,7 @@ const video: Record = { '.mpeg': ['video/mpeg'], '.mpg': ['video/mpeg'], '.mts': ['video/mp2t'], + '.vob': ['video/mpeg'], '.webm': ['video/webm'], '.wmv': ['video/x-ms-wmv'], }; From 60c783bbe9b9fdf91e7ba2a28974018fd291f574 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:11:19 -0500 Subject: [PATCH 528/599] fix(server): partial fallback for hardware transcoding (#14611) --- server/src/interfaces/media.interface.ts | 5 + server/src/services/media.service.spec.ts | 106 ++++++++++++---------- server/src/services/media.service.ts | 77 +++++++--------- server/src/utils/media.ts | 54 +++++------ 4 files changed, 121 insertions(+), 121 deletions(-) diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 468a6ad88d..b90dfb483c 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -130,6 +130,11 @@ export interface ProbeOptions { countFrames: boolean; } +export interface VideoInterfaces { + dri: string[]; + mali: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 909b9d02e3..36a9045677 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,4 +1,3 @@ -import type { Stats } from 'node:fs'; import { SystemConfig } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -303,7 +302,7 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getById.mockResolvedValue(assetStub.video); - await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error); + await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -770,6 +769,7 @@ describe(MediaService.name, () => { describe('handleVideoConversion', () => { beforeEach(() => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { @@ -826,7 +826,7 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined(); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1079,7 +1079,7 @@ describe(MediaService.name, () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow(); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1434,7 +1434,7 @@ describe(MediaService.name, () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1442,7 +1442,7 @@ describe(MediaService.name, () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); @@ -1628,7 +1628,6 @@ describe(MediaService.name, () => { }); it('should set options for qsv', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1664,7 +1663,6 @@ describe(MediaService.name, () => { }); it('should set options for qsv with custom dri node', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { @@ -1690,7 +1688,6 @@ describe(MediaService.name, () => { }); it('should omit preset for qsv if invalid', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1710,7 +1707,6 @@ describe(MediaService.name, () => { }); it('should set low power mode for qsv if target video codec is vp9', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1730,17 +1726,18 @@ describe(MediaService.name, () => { }); it('should fail for qsv if no hw devices', async () => { - storageMock.readdir.mockRejectedValue(new Error('Could not read directory')); + sut.videoInterfaces = { dri: [], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); - expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.'); }); it('should prefer higher index renderD* device for qsv', async () => { - storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']); + sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1760,7 +1757,6 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for qsv if enabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, @@ -1790,7 +1786,6 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, @@ -1820,7 +1815,7 @@ describe(MediaService.name, () => { }); it('should use preferred device for qsv when hardware decoding', async () => { - storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']); + sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, @@ -1840,7 +1835,6 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for qsv if input is not yuv420p', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, @@ -1866,7 +1860,6 @@ describe(MediaService.name, () => { }); it('should set options for vaapi', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1898,7 +1891,6 @@ describe(MediaService.name, () => { }); it('should set vbr options for vaapi when max bitrate is enabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1924,7 +1916,6 @@ describe(MediaService.name, () => { }); it('should set cq options for vaapi when max bitrate is disabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1950,7 +1941,6 @@ describe(MediaService.name, () => { }); it('should omit preset for vaapi if invalid', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1970,7 +1960,7 @@ describe(MediaService.name, () => { }); it('should prefer higher index renderD* device for vaapi', async () => { - storageMock.readdir.mockResolvedValue(['card1', 'renderD129', 'card0', 'renderD128']); + sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1990,7 +1980,7 @@ describe(MediaService.name, () => { }); it('should select specific gpu node if selected', async () => { - storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); + sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, @@ -2012,7 +2002,6 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for vaapi if enabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, @@ -2041,7 +2030,6 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, @@ -2066,7 +2054,6 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for vaapi if input is not yuv420p', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, @@ -2087,7 +2074,7 @@ describe(MediaService.name, () => { }); it('should use preferred device for vaapi when hardware decoding', async () => { - storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']); + sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, @@ -2106,8 +2093,47 @@ describe(MediaService.name, () => { ); }); - it('should fallback to sw transcoding if hw transcoding fails', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); + it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledTimes(2); + expect(mediaMock.transcode).toHaveBeenLastCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.arrayContaining([ + '-init_hw_device vaapi=accel:/dev/dri/renderD128', + '-filter_hw_device accel', + ]), + outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), + twoPass: false, + }), + ); + }); + + it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + mediaMock.transcode.mockRejectedValueOnce(new Error('error')); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledTimes(3); + expect(mediaMock.transcode).toHaveBeenLastCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:v h264']), + twoPass: false, + }), + ); + }); + + it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -2126,17 +2152,15 @@ describe(MediaService.name, () => { }); it('should fail for vaapi if no hw devices', async () => { - storageMock.readdir.mockResolvedValue([]); + sut.videoInterfaces = { dri: [], mali: true }; mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mediaMock.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -2171,8 +2195,6 @@ describe(MediaService.name, () => { }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); systemMock.get.mockResolvedValue({ ffmpeg: { @@ -2196,8 +2218,6 @@ describe(MediaService.name, () => { }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -2216,8 +2236,6 @@ describe(MediaService.name, () => { }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -2240,8 +2258,7 @@ describe(MediaService.name, () => { }); it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats); + sut.videoInterfaces = { dri: ['renderD128'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -2262,8 +2279,6 @@ describe(MediaService.name, () => { }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, @@ -2286,8 +2301,7 @@ describe(MediaService.name, () => { }); it('should use software tone-mapping if opencl is not available', async () => { - storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats); + sut.videoInterfaces = { dri: ['renderD128'], mali: false }; mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index f433748ec4..7036bd32e8 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { OnJob } from 'src/decorators'; +import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { @@ -27,7 +27,7 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/interfaces/media.interface'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; @@ -36,8 +36,13 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class MediaService extends BaseService { - private maliOpenCL?: boolean; - private devices?: string[]; + videoInterfaces: VideoInterfaces = { dri: [], mali: false }; + + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap() { + const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]); + this.videoInterfaces = { dri, mali }; + } @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) async handleQueueGenerateThumbnails({ force }: JobOf): Promise { @@ -300,19 +305,19 @@ export class MediaService extends BaseService { const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs }); - const mainVideoStream = this.getMainStream(videoStreams); - const mainAudioStream = this.getMainStream(audioStreams); - if (!mainVideoStream || !format.formatName) { + const videoStream = this.getMainStream(videoStreams); + const audioStream = this.getMainStream(audioStreams); + if (!videoStream || !format.formatName) { return JobStatus.FAILED; } - if (!mainVideoStream.height || !mainVideoStream.width) { + if (!videoStream.height || !videoStream.width) { this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); return JobStatus.FAILED; } - const { ffmpeg } = await this.getConfig({ withCache: true }); - const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); + let { ffmpeg } = await this.getConfig({ withCache: true }); + const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); @@ -325,15 +330,7 @@ export class MediaService extends BaseService { return JobStatus.SKIPPED; } - let command: TranscodeCommand; - try { - const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); - command = config.getCommand(target, mainVideoStream, mainAudioStream); - } catch (error) { - this.logger.error(`An error occurred while configuring transcoding options: ${error}`); - return JobStatus.FAILED; - } - + const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`); } else { @@ -354,8 +351,8 @@ export class MediaService extends BaseService { if (ffmpeg.accelDecode) { try { this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`); - const config = BaseConfig.create({ ...ffmpeg, accelDecode: false }); - command = config.getCommand(target, mainVideoStream, mainAudioStream); + ffmpeg = { ...ffmpeg, accelDecode: false }; + const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); await this.mediaRepository.transcode(input, output, command); partialFallbackSuccess = true; } catch (error: any) { @@ -365,8 +362,8 @@ export class MediaService extends BaseService { if (!partialFallbackSuccess) { this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); - const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); - command = config.getCommand(target, mainVideoStream, mainAudioStream); + ffmpeg = { ...ffmpeg, accel: TranscodeHWAccel.DISABLED }; + const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); await this.mediaRepository.transcode(input, output, command); } } @@ -507,30 +504,24 @@ export class MediaService extends BaseService { } private async getDevices() { - if (!this.devices) { - try { - this.devices = await this.storageRepository.readdir('/dev/dri'); - } catch { - this.logger.debug('No devices found in /dev/dri.'); - this.devices = []; - } + try { + return await this.storageRepository.readdir('/dev/dri'); + } catch { + this.logger.debug('No devices found in /dev/dri.'); + return []; } - - return this.devices; } private async hasMaliOpenCL() { - if (this.maliOpenCL === undefined) { - try { - const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); - const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); - this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); - } catch { - this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping'); - this.maliOpenCL = false; - } + try { + const [maliIcdStat, maliDeviceStat] = await Promise.all([ + this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'), + this.storageRepository.stat('/dev/mali0'), + ]); + return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); + } catch { + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping'); + return false; } - - return this.maliOpenCL; } } diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 226f95b4bb..678e8cb15a 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -7,6 +7,7 @@ import { VideoCodecHWConfig, VideoCodecSWConfig, VideoFormat, + VideoInterfaces, VideoStreamInfo, } from 'src/interfaces/media.interface'; @@ -14,11 +15,11 @@ export class BaseConfig implements VideoCodecSWConfig { readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; protected constructor(protected config: SystemConfigFFmpegDto) {} - static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig { + static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig { if (config.accel === TranscodeHWAccel.DISABLED) { return this.getSWCodecConfig(config); } - return this.getHWCodecConfig(config, devices, hasMaliOpenCL); + return this.getHWCodecConfig(config, interfaces); } private static getSWCodecConfig(config: SystemConfigFFmpegDto) { @@ -41,27 +42,31 @@ export class BaseConfig implements VideoCodecSWConfig { } } - private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) { + private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) { let handler: VideoCodecHWConfig; switch (config.accel) { case TranscodeHWAccel.NVENC: { - handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config); + handler = config.accelDecode + ? new NvencHwDecodeConfig(config, interfaces) + : new NvencSwDecodeConfig(config, interfaces); break; } case TranscodeHWAccel.QSV: { - handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices); + handler = config.accelDecode + ? new QsvHwDecodeConfig(config, interfaces) + : new QsvSwDecodeConfig(config, interfaces); break; } case TranscodeHWAccel.VAAPI: { handler = config.accelDecode - ? new VaapiHwDecodeConfig(config, devices) - : new VaapiSwDecodeConfig(config, devices); + ? new VaapiHwDecodeConfig(config, interfaces) + : new VaapiSwDecodeConfig(config, interfaces); break; } case TranscodeHWAccel.RKMPP: { handler = config.accelDecode - ? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL) - : new RkmppSwDecodeConfig(config, devices); + ? new RkmppHwDecodeConfig(config, interfaces) + : new RkmppSwDecodeConfig(config, interfaces); break; } default: { @@ -323,13 +328,15 @@ export class BaseConfig implements VideoCodecSWConfig { export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { protected device: string; + protected interfaces: VideoInterfaces; constructor( protected config: SystemConfigFFmpegDto, - devices: string[] = [], + interfaces: VideoInterfaces, ) { super(config); - this.device = this.getDevice(devices); + this.interfaces = interfaces; + this.device = this.getDevice(interfaces); } getSupportedCodecs() { @@ -346,16 +353,16 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { }); } - getDevice(devices: string[]) { + getDevice({ dri }: VideoInterfaces) { if (this.config.preferredHwDevice === 'auto') { // eslint-disable-next-line unicorn/no-array-reduce - return `/dev/dri/${this.validateDevices(devices).reduce(function (a, b) { + return `/dev/dri/${this.validateDevices(dri).reduce(function (a, b) { return a.localeCompare(b) < 0 ? b : a; })}`; } const deviceName = this.config.preferredHwDevice.replace('/dev/dri/', ''); - if (!devices.includes(deviceName)) { + if (!dri.includes(deviceName)) { throw new Error(`Device '${deviceName}' does not exist. If using Docker, make sure this device is mounted`); } @@ -886,13 +893,6 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig { } export class RkmppSwDecodeConfig extends BaseHWConfig { - constructor( - protected config: SystemConfigFFmpegDto, - devices: string[] = [], - ) { - super(config, devices); - } - eligibleForTwoPass(): boolean { return false; } @@ -937,16 +937,6 @@ export class RkmppSwDecodeConfig extends BaseHWConfig { } export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { - protected hasMaliOpenCL: boolean; - constructor( - protected config: SystemConfigFFmpegDto, - devices: string[] = [], - hasMaliOpenCL = false, - ) { - super(config, devices); - this.hasMaliOpenCL = hasMaliOpenCL; - } - getBaseInputOptions() { return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate']; } @@ -954,7 +944,7 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { getFilterOptions(videoStream: VideoStreamInfo) { if (this.shouldToneMap(videoStream)) { const { primaries, transfer, matrix } = this.getColors(); - if (this.hasMaliOpenCL) { + if (this.interfaces.mali) { return [ // use RKMPP for scaling, OpenCL for tone mapping `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`, From 25ca3b112483a8fa42d534473525d450dc7b3f3a Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:22:37 -0500 Subject: [PATCH 529/599] refactor(server): use `includeNull` in query for search suggestions (#14626) * use `includeNull` * push down `includeNull` into query, inner joins * remove filter * update sql * fix tests * maybe fix e2e * more e2e tests * handle no exif row * whoops * update sql --- e2e/src/api/specs/search.e2e-spec.ts | 117 ++++++++++++++++++- server/src/interfaces/search.interface.ts | 24 +++- server/src/queries/search.repository.sql | 25 ++-- server/src/repositories/search.repository.ts | 50 +++++--- server/src/services/search.service.spec.ts | 78 +++++++++++-- server/src/services/search.service.ts | 17 +-- 6 files changed, 259 insertions(+), 52 deletions(-) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index 627fbb3e9e..11bb37be18 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -98,6 +98,7 @@ describe('/search', () => { { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg + { latitude: 0, longitude: 0 }, // null island ]; const updates = coordinates.map((dto, i) => @@ -532,7 +533,7 @@ describe('/search', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should get suggestions for country', async () => { + it('should get suggestions for country (including null)', async () => { const { status, body } = await request(app) .get('/search/suggestions?type=country&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); @@ -555,7 +556,29 @@ describe('/search', () => { expect(status).toBe(200); }); - it('should get suggestions for state', async () => { + it('should get suggestions for country', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=country') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Cuba', + 'France', + 'Georgia', + 'Germany', + 'Ghana', + 'Japan', + 'Morocco', + "People's Republic of China", + 'Russian Federation', + 'Singapore', + 'Spain', + 'Switzerland', + 'United States of America', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for state (including null)', async () => { const { status, body } = await request(app) .get('/search/suggestions?type=state&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); @@ -579,7 +602,30 @@ describe('/search', () => { expect(status).toBe(200); }); - it('should get suggestions for city', async () => { + it('should get suggestions for state', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=state') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Andalusia', + 'Berlin', + 'Glarus', + 'Greater Accra', + 'Havana', + 'Île-de-France', + 'Marrakesh-Safi', + 'Mississippi', + 'New York', + 'Shanghai', + 'St.-Petersburg', + 'Tbilisi', + 'Tokyo', + 'Virginia', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for city (including null)', async () => { const { status, body } = await request(app) .get('/search/suggestions?type=city&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); @@ -604,7 +650,31 @@ describe('/search', () => { expect(status).toBe(200); }); - it('should get suggestions for camera make', async () => { + it('should get suggestions for city', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=city') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Accra', + 'Berlin', + 'Glarus', + 'Havana', + 'Marrakesh', + 'Montalbán de Córdoba', + 'New York City', + 'Novena', + 'Paris', + 'Philadelphia', + 'Saint Petersburg', + 'Shanghai', + 'Stanley', + 'Tbilisi', + 'Tokyo', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for camera make (including null)', async () => { const { status, body } = await request(app) .get('/search/suggestions?type=camera-make&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); @@ -621,7 +691,23 @@ describe('/search', () => { expect(status).toBe(200); }); - it('should get suggestions for camera model', async () => { + it('should get suggestions for camera make', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-make') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Apple', + 'Canon', + 'FUJIFILM', + 'NIKON CORPORATION', + 'PENTAX Corporation', + 'samsung', + 'SONY', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for camera model (including null)', async () => { const { status, body } = await request(app) .get('/search/suggestions?type=camera-model&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); @@ -642,5 +728,26 @@ describe('/search', () => { ]); expect(status).toBe(200); }); + + it('should get suggestions for camera model', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-model') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Canon EOS 7D', + 'Canon EOS R5', + 'DSLR-A550', + 'FinePix S3Pro', + 'iPhone 7', + 'NIKON D700', + 'NIKON D750', + 'NIKON D80', + 'PENTAX K10D', + 'SM-F711N', + 'SM-S906U', + 'SM-T970', + ]); + expect(status).toBe(200); + }); }); }); diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 87bf1bc4b1..d59291c883 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -170,6 +170,22 @@ export interface AssetDuplicateResult { distance: number; } +export interface GetStatesOptions { + country?: string; +} + +export interface GetCitiesOptions extends GetStatesOptions { + state?: string; +} + +export interface GetCameraModelsOptions { + make?: string; +} + +export interface GetCameraMakesOptions { + model?: string; +} + export interface ISearchRepository { searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; @@ -183,8 +199,8 @@ export interface ISearchRepository { getDimensionSize(): Promise; setDimensionSize(dimSize: number): Promise; getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], country?: string): Promise>; - getCities(userIds: string[], country?: string, state?: string): Promise>; - getCameraMakes(userIds: string[], model?: string): Promise>; - getCameraModels(userIds: string[], make?: string): Promise>; + getStates(userIds: string[], options: GetStatesOptions): Promise>; + getCities(userIds: string[], options: GetCitiesOptions): Promise>; + getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise>; + getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise>; } diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 7de61ad03c..1084375059 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -585,52 +585,57 @@ SELECT DISTINCT ON ("exif"."country") "exif"."country" AS "country" FROM "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" IN ($1) + AND "exif"."country" != '' + AND "exif"."country" IS NOT NULL -- SearchRepository.getStates SELECT DISTINCT ON ("exif"."state") "exif"."state" AS "state" FROM "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" IN ($1) - AND "exif"."country" = $2 + AND "exif"."state" != '' + AND "exif"."state" IS NOT NULL -- SearchRepository.getCities SELECT DISTINCT ON ("exif"."city") "exif"."city" AS "city" FROM "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" IN ($1) - AND "exif"."country" = $2 - AND "exif"."state" = $3 + AND "exif"."city" != '' + AND "exif"."city" IS NOT NULL -- SearchRepository.getCameraMakes SELECT DISTINCT ON ("exif"."make") "exif"."make" AS "make" FROM "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" IN ($1) - AND "exif"."model" = $2 + AND "exif"."make" != '' + AND "exif"."make" IS NOT NULL -- SearchRepository.getCameraModels SELECT DISTINCT ON ("exif"."model") "exif"."model" AS "model" FROM "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + INNER JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE "asset"."ownerId" IN ($1) - AND "exif"."make" = $2 + AND "exif"."model" != '' + AND "exif"."model" IS NOT NULL diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index ba7d779e02..0a529f2f6e 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -17,6 +17,10 @@ import { AssetSearchOptions, FaceEmbeddingSearch, FaceSearchResult, + GetCameraMakesOptions, + GetCameraModelsOptions, + GetCitiesOptions, + GetStatesOptions, ISearchRepository, SearchPaginationOptions, SmartSearchOptions, @@ -342,23 +346,27 @@ export class SearchRepository implements ISearchRepository { @GenerateSql({ params: [[DummyValue.UUID]] }) async getCountries(userIds: string[]): Promise { - const results = await this.exifRepository + const query = this.exifRepository .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') + .innerJoin('exif.asset', 'asset') .where('asset.ownerId IN (:...userIds )', { userIds }) + .andWhere(`exif.country != ''`) + .andWhere('exif.country IS NOT NULL') .select('exif.country', 'country') - .distinctOn(['exif.country']) - .getRawMany<{ country: string }>(); + .distinctOn(['exif.country']); - return results.map(({ country }) => country).filter((item) => item !== ''); + const results = await query.getRawMany<{ country: string }>(); + return results.map(({ country }) => country); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getStates(userIds: string[], country: string | undefined): Promise { + async getStates(userIds: string[], { country }: GetStatesOptions): Promise { const query = this.exifRepository .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') + .innerJoin('exif.asset', 'asset') .where('asset.ownerId IN (:...userIds )', { userIds }) + .andWhere(`exif.state != ''`) + .andWhere('exif.state IS NOT NULL') .select('exif.state', 'state') .distinctOn(['exif.state']); @@ -367,16 +375,17 @@ export class SearchRepository implements ISearchRepository { } const result = await query.getRawMany<{ state: string }>(); - - return result.map(({ state }) => state).filter((item) => item !== ''); + return result.map(({ state }) => state); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) - async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { + async getCities(userIds: string[], { country, state }: GetCitiesOptions): Promise { const query = this.exifRepository .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') + .innerJoin('exif.asset', 'asset') .where('asset.ownerId IN (:...userIds )', { userIds }) + .andWhere(`exif.city != ''`) + .andWhere('exif.city IS NOT NULL') .select('exif.city', 'city') .distinctOn(['exif.city']); @@ -389,16 +398,17 @@ export class SearchRepository implements ISearchRepository { } const results = await query.getRawMany<{ city: string }>(); - - return results.map(({ city }) => city).filter((item) => item !== ''); + return results.map(({ city }) => city); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraMakes(userIds: string[], model: string | undefined): Promise { + async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise { const query = this.exifRepository .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') + .innerJoin('exif.asset', 'asset') .where('asset.ownerId IN (:...userIds )', { userIds }) + .andWhere(`exif.make != ''`) + .andWhere('exif.make IS NOT NULL') .select('exif.make', 'make') .distinctOn(['exif.make']); @@ -407,15 +417,17 @@ export class SearchRepository implements ISearchRepository { } const results = await query.getRawMany<{ make: string }>(); - return results.map(({ make }) => make).filter((item) => item !== ''); + return results.map(({ make }) => make); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraModels(userIds: string[], make: string | undefined): Promise { + async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise { const query = this.exifRepository .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') + .innerJoin('exif.asset', 'asset') .where('asset.ownerId IN (:...userIds )', { userIds }) + .andWhere(`exif.model != ''`) + .andWhere('exif.model IS NOT NULL') .select('exif.model', 'model') .distinctOn(['exif.model']); @@ -424,7 +436,7 @@ export class SearchRepository implements ISearchRepository { } const results = await query.getRawMany<{ model: string }>(); - return results.map(({ model }) => model).filter((item) => item !== ''); + return results.map(({ model }) => model); } private getRuntimeConfig(numResults?: number): string | undefined { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 0f95d88083..3933526167 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -59,20 +59,84 @@ describe(SearchService.name, () => { }); describe('getSearchSuggestions', () => { - it('should return search suggestions (including null)', async () => { - searchMock.getCountries.mockResolvedValue(['USA', null]); + it('should return search suggestions for country', async () => { + searchMock.getCountries.mockResolvedValue(['USA']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), + ).resolves.toEqual(['USA']); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + }); + + it('should return search suggestions for country (including null)', async () => { + searchMock.getCountries.mockResolvedValue(['USA']); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); - it('should return search suggestions (without null)', async () => { - searchMock.getCountries.mockResolvedValue(['USA', null]); + it('should return search suggestions for state', async () => { + searchMock.getStates.mockResolvedValue(['California']); await expect( - sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), - ).resolves.toEqual(['USA']); - expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }), + ).resolves.toEqual(['California']); + expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for state (including null)', async () => { + searchMock.getStates.mockResolvedValue(['California']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }), + ).resolves.toEqual(['California', null]); + expect(searchMock.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for city', async () => { + searchMock.getCities.mockResolvedValue(['Denver']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }), + ).resolves.toEqual(['Denver']); + expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for city (including null)', async () => { + searchMock.getCities.mockResolvedValue(['Denver']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }), + ).resolves.toEqual(['Denver', null]); + expect(searchMock.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera make', async () => { + searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }), + ).resolves.toEqual(['Nikon']); + expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera make (including null)', async () => { + searchMock.getCameraMakes.mockResolvedValue(['Nikon']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }), + ).resolves.toEqual(['Nikon', null]); + expect(searchMock.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera model', async () => { + searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }), + ).resolves.toEqual(['Fujifilm X100VI']); + expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera model (including null)', async () => { + searchMock.getCameraModels.mockResolvedValue(['Fujifilm X100VI']); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }), + ).resolves.toEqual(['Fujifilm X100VI', null]); + expect(searchMock.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index bf5bf9e311..7fc947a8b5 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -108,8 +108,11 @@ export class SearchService extends BaseService { async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) { const userIds = await this.getUserIdsToSearch(auth); - const results = await this.getSuggestions(userIds, dto); - return results.filter((result) => (dto.includeNull ? true : result !== null)); + const suggestions = await this.getSuggestions(userIds, dto); + if (dto.includeNull) { + suggestions.push(null); + } + return suggestions; } private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { @@ -118,19 +121,19 @@ export class SearchService extends BaseService { return this.searchRepository.getCountries(userIds); } case SearchSuggestionType.STATE: { - return this.searchRepository.getStates(userIds, dto.country); + return this.searchRepository.getStates(userIds, dto); } case SearchSuggestionType.CITY: { - return this.searchRepository.getCities(userIds, dto.country, dto.state); + return this.searchRepository.getCities(userIds, dto); } case SearchSuggestionType.CAMERA_MAKE: { - return this.searchRepository.getCameraMakes(userIds, dto.model); + return this.searchRepository.getCameraMakes(userIds, dto); } case SearchSuggestionType.CAMERA_MODEL: { - return this.searchRepository.getCameraModels(userIds, dto.make); + return this.searchRepository.getCameraModels(userIds, dto); } default: { - return []; + return [] as (string | null)[]; } } } From 9eff1c4b34ace08871c8abf628adc9fcfe37a41d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:22:47 -0500 Subject: [PATCH 530/599] refactor(server): move filters to getByDayOfYear query (#14628) move filters to getByDayOfYear query --- server/src/interfaces/asset.interface.ts | 7 ++++- server/src/queries/asset.repository.sql | 19 ++++++-------- server/src/repositories/asset.repository.ts | 23 +++++++++++++--- server/src/services/asset.service.spec.ts | 15 ++++++++++- server/src/services/asset.service.ts | 29 +++++---------------- 5 files changed, 55 insertions(+), 38 deletions(-) diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 1b32c57d41..b25e42ba0e 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -146,6 +146,11 @@ export interface UpsertFileOptions { export type AssetPathEntity = Pick; +export interface DayOfYearAssets { + yearsAgo: number; + assets: AssetEntity[]; +} + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { @@ -156,7 +161,7 @@ export interface IAssetRepository { select?: FindOptionsSelect, ): Promise; getByIdsWithAllRelations(ids: string[]): Promise; - getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; + getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise; getByChecksums(userId: string, checksums: Buffer[]): Promise; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e7f5b558b0..f4b1b2fea1 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -68,22 +68,19 @@ SELECT FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" - LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" + INNER JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" WHERE ( - "entity"."ownerId" IN ($1) - AND "entity"."isVisible" = true - AND "entity"."isArchived" = false + "files"."type" = $1 AND EXTRACT( - DAY + YEAR + FROM + CURRENT_DATE AT TIME ZONE 'UTC' + ) - EXTRACT( + YEAR FROM "entity"."localDateTime" AT TIME ZONE 'UTC' - ) = $2 - AND EXTRACT( - MONTH - FROM - "entity"."localDateTime" AT TIME ZONE 'UTC' - ) = $3 + ) > 0 ) AND ("entity"."deletedAt" IS NULL) ORDER BY diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ce7d257b40..b3066a37bc 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -17,6 +17,7 @@ import { AssetUpdateAllOptions, AssetUpdateDuplicateOptions, AssetUpdateOptions, + DayOfYearAssets, IAssetRepository, LivePhotoSearchOptions, MonthDay, @@ -74,8 +75,8 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { - return this.repository + async getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + const assets = await this.repository .createQueryBuilder('entity') .where( `entity.ownerId IN (:...ownerIds) @@ -90,9 +91,25 @@ export class AssetRepository implements IAssetRepository { }, ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .leftJoinAndSelect('entity.files', 'files') + .innerJoinAndSelect('entity.files', 'files') + .where('files.type = :type', { type: AssetFileType.THUMBNAIL }) + .andWhere( + `EXTRACT(YEAR FROM CURRENT_DATE AT TIME ZONE 'UTC') - EXTRACT(YEAR FROM entity.localDateTime AT TIME ZONE 'UTC') > 0`, + ) .orderBy('entity.fileCreatedAt', 'ASC') .getMany(); + + const groups: Record = {}; + const currentYear = new Date().getFullYear(); + for (const asset of assets) { + const yearsAgo = currentYear - asset.localDateTime.getFullYear(); + if (!groups[yearsAgo]) { + groups[yearsAgo] = { yearsAgo, assets: [] }; + } + groups[yearsAgo].assets.push(asset); + } + + return Object.values(groups); } @GenerateSql({ params: [[DummyValue.UUID]] }) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 9063df9dc2..5aab5032af 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -80,7 +80,20 @@ describe(AssetService.name, () => { const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3, image4]); + assetMock.getByDayOfYear.mockResolvedValue([ + { + yearsAgo: 1, + assets: [image1, image2], + }, + { + yearsAgo: 9, + assets: [image3], + }, + { + yearsAgo: 15, + assets: [image4], + }, + ]); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 98d6ec00f6..8751037119 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -43,28 +43,13 @@ export class AssetService extends BaseService { }); const userIds = [auth.user.id, ...partnerIds]; - const assets = await this.assetRepository.getByDayOfYear(userIds, dto); - const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile); - const groups: Record = {}; - const currentYear = new Date().getFullYear(); - for (const asset of assetsWithThumbnails) { - const yearsAgo = currentYear - asset.localDateTime.getFullYear(); - if (!groups[yearsAgo]) { - groups[yearsAgo] = []; - } - groups[yearsAgo].push(asset); - } - - return Object.keys(groups) - .map(Number) - .sort((a, b) => a - b) - .filter((yearsAgo) => yearsAgo > 0) - .map((yearsAgo) => ({ - yearsAgo, - // TODO move this to clients - title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })), - })); + const groups = await this.assetRepository.getByDayOfYear(userIds, dto); + return groups.map(({ yearsAgo, assets }) => ({ + yearsAgo, + // TODO move this to clients + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, + assets: assets.map((asset) => mapAsset(asset, { auth })), + })); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { From 345f918784ca8701b34c5d28be2edd74a89b0cb2 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Wed, 11 Dec 2024 01:42:45 +0400 Subject: [PATCH 531/599] chore(docs): stronger discouraging of non-Linux installations (#14620) * no windows! * 2 * 3 * Update docs/docs/install/requirements.md Co-authored-by: bo0tzz * Update requirements.md --------- Co-authored-by: bo0tzz --- docs/docs/administration/backup-and-restore.md | 7 ++++++- docs/docs/install/requirements.md | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 9ae4e3e51f..1f8d489728 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -65,12 +65,17 @@ docker compose up -d # Start remainder of Immich apps docker compose down -v # CAUTION! Deletes all Immich data to start from scratch ## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database # Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch +## You should mount the backup (as a volume, example: - 'C:\path\to\backup\dump.sql':/dump.sql) into the immich_postgres container using the docker-compose.yml docker compose pull # Update to latest version of Immich (if desired) docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up +docker exec -it immich_postgres bash # Enter the Docker shell and run the following command # Check the database user if you deviated from the default -gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup +cat "/dump.sql" \ +| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ +| psql --username=postgres # Restore Backup +exit # Exit the Docker shell docker compose up -d # Start remainder of Immich apps ``` diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 74c4a2f831..6dc613389e 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -18,8 +18,11 @@ Immich requires the command `docker compose` - the similarly named `docker-compo ## Hardware - **OS**: Recommended Linux operating system (Ubuntu, Debian, etc). - - Windows is supported with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/). - - macOS is supported with [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/). + - Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged. + Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced. + If you still want to try to use a non-Linux OS, you can set it up as follows: + - Windows: [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/). + - macOS: [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/). - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. From 70b4647a21df9c6d6ed875a61ca8d70e2bc88f7d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 10 Dec 2024 15:55:59 -0600 Subject: [PATCH 532/599] chore(mobile): post release tasks (#14603) --- mobile/ios/Runner/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index e85afdc852..28d21e266e 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,7 +58,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.122.0 + 1.122.2 CFBundleSignature ???? CFBundleVersion From f6909a3b110fb4a82ffab8f9f865dd19d737107c Mon Sep 17 00:00:00 2001 From: vladd11 Date: Wed, 11 Dec 2024 00:58:14 +0300 Subject: [PATCH 533/599] chore(docs): add Kodi plugin for Immich to the Community Projects list (#14586) --- docs/src/components/community-projects.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 596bf9dfc4..2dbab979f2 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -89,6 +89,11 @@ const projects: CommunityProjectProps[] = [ 'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.', url: 'https://github.com/alangrainger/immich-public-proxy', }, + { + title: 'Immich Kodi', + description: 'Unofficial Kodi plugin for Immich.', + url: 'https://github.com/vladd11/immich-kodi', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { From 7cae25c28b6ecdcb7bc0cd8976212d53abe5e883 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:59:45 -0600 Subject: [PATCH 534/599] chore(deps): update prom/prometheus docker digest to 565ee86 (#14535) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 704f3bdfc8..d58b20ef76 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -91,7 +91,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f + image: prom/prometheus@sha256:565ee86501224ebbb98fc10b332fa54440b100469924003359edf49cbce374bd volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From bcc438eafbd6908306a41bc82afe0fd2dfe6fecc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 22:00:01 +0000 Subject: [PATCH 535/599] fix(deps): update dependency python-multipart to v0.0.18 [security] (#14458) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 867b76c9e5..229db1303b 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2746,13 +2746,13 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.17" +version = "0.0.19" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"}, - {file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"}, + {file = "python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d"}, + {file = "python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc"}, ] [[package]] From 5814a1b22320b75137086c35407e9864519ad17b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:07:16 -0600 Subject: [PATCH 536/599] chore(deps): update docker/build-push-action action to v6.10.0 (#14631) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 7052fa6ef9..da383c3e2d 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.9.0 + uses: docker/build-push-action@v6.10.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 034fbe0008..7ec0cc0947 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -174,7 +174,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.9.0 + uses: docker/build-push-action@v6.10.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -265,7 +265,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.9.0 + uses: docker/build-push-action@v6.10.0 with: context: ${{ env.context }} file: ${{ env.file }} From 11f585d0adf384cbabae78a8ec3b4709449cb90f Mon Sep 17 00:00:00 2001 From: dvbthien <89862334+dvbthien@users.noreply.github.com> Date: Wed, 11 Dec 2024 23:30:56 +0700 Subject: [PATCH 537/599] refactor(mobile): refactor theme management (#14415) --- mobile/lib/constants/colors.dart | 23 + mobile/lib/main.dart | 36 +- mobile/lib/pages/common/settings.page.dart | 1 + mobile/lib/pages/search/map/map.page.dart | 2 +- .../search/map/map_location_picker.page.dart | 2 +- mobile/lib/providers/theme.provider.dart | 74 ++++ mobile/lib/services/app_settings.service.dart | 2 +- .../color_scheme.dart} | 29 +- mobile/lib/theme/dynamic_theme.dart | 38 ++ .../theme_data.dart} | 407 +++++++----------- .../asset_viewer/motion_photo_button.dart | 2 +- .../widgets/asset_viewer/video_position.dart | 2 +- mobile/lib/widgets/backup/error_chip.dart | 2 +- .../lib/widgets/backup/error_chip_text.dart | 2 +- .../lib/widgets/map/map_theme_override.dart | 11 +- mobile/lib/widgets/map/map_thumbnail.dart | 2 +- .../primary_color_setting.dart | 16 +- .../preference_settings/theme_setting.dart | 2 +- .../modules/map/map_theme_override_test.dart | 12 +- 19 files changed, 343 insertions(+), 322 deletions(-) create mode 100644 mobile/lib/constants/colors.dart create mode 100644 mobile/lib/providers/theme.provider.dart rename mobile/lib/{constants/immich_colors.dart => theme/color_scheme.dart} (80%) create mode 100644 mobile/lib/theme/dynamic_theme.dart rename mobile/lib/{utils/immich_app_theme.dart => theme/theme_data.dart} (58%) diff --git a/mobile/lib/constants/colors.dart b/mobile/lib/constants/colors.dart new file mode 100644 index 0000000000..ade878d6f6 --- /dev/null +++ b/mobile/lib/constants/colors.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +enum ImmichColorPreset { + indigo, + deepPurple, + pink, + red, + orange, + yellow, + lime, + green, + cyan, + slateGray +} + +const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo; +const String defaultColorPresetName = "indigo"; + +const Color immichBrandColorLight = Color(0xFF4150AF); +const Color immichBrandColorDark = Color(0xFFACCBFA); +const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); +const Color red400 = Color(0xFFEF5350); +const Color grey200 = Color(0xFFEEEEEE); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7729972aa2..807212fc65 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,23 +4,26 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:timezone/data/latest.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/utils/download.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; -import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -30,16 +33,15 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:immich_mobile/utils/immich_app_theme.dart'; import 'package:immich_mobile/utils/migration.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:immich_mobile/utils/download.dart'; +import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/theme/dynamic_theme.dart'; void main() async { ImmichWidgetsBinding(); @@ -69,12 +71,12 @@ Future initApp() async { } } - await fetchSystemPalette(); + await DynamicTheme.fetchSystemPalette(); // Initialize Immich Logger Service ImmichLogger(); - var log = Logger("ImmichErrorLogger"); + final log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { FlutterError.presentError(details); diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index ba3150c046..3cbded1787 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -133,6 +133,7 @@ class _MobileLayout extends StatelessWidget { ).tr(), subtitle: Text( setting.subtitle, + style: context.textTheme.labelLarge, ).tr(), onTap: () => context.pushRoute(SettingsSubRoute(section: setting)), diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 10fe8de541..52ce13f958 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -264,7 +264,7 @@ class MapPage extends HookConsumerWidget { selectedAssets.value = selected ? selection : {}; } - return MapThemeOveride( + return MapThemeOverride( mapBuilder: (style) => context.isMobile // Single-column ? Scaffold( diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index 2fd1e1ee9e..487de69a1e 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -58,7 +58,7 @@ class MapLocationPickerPage extends HookConsumerWidget { controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); } - return MapThemeOveride( + return MapThemeOverride( mapBuilder: (style) => Builder( builder: (ctx) => Scaffold( backgroundColor: ctx.themeData.cardColor, diff --git a/mobile/lib/providers/theme.provider.dart b/mobile/lib/providers/theme.provider.dart new file mode 100644 index 0000000000..73623bd026 --- /dev/null +++ b/mobile/lib/providers/theme.provider.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/theme/color_scheme.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/theme/dynamic_theme.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +final immichThemeModeProvider = StateProvider((ref) { + final themeMode = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.themeMode); + + debugPrint("Current themeMode $themeMode"); + + if (themeMode == ThemeMode.light.name) { + return ThemeMode.light; + } else if (themeMode == ThemeMode.dark.name) { + return ThemeMode.dark; + } else { + return ThemeMode.system; + } +}); + +final immichThemePresetProvider = StateProvider((ref) { + final appSettingsProvider = ref.watch(appSettingsServiceProvider); + final primaryColorPreset = + appSettingsProvider.getSetting(AppSettingsEnum.primaryColor); + + debugPrint("Current theme preset $primaryColorPreset"); + + try { + return ImmichColorPreset.values + .firstWhere((e) => e.name == primaryColorPreset); + } catch (e) { + debugPrint( + "Theme preset $primaryColorPreset not found. Applying default preset.", + ); + appSettingsProvider.setSetting( + AppSettingsEnum.primaryColor, + defaultColorPresetName, + ); + return defaultColorPreset; + } +}); + +final dynamicThemeSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.dynamicTheme); +}); + +final colorfulInterfaceSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.colorfulInterface); +}); + +// Provider for current selected theme +final immichThemeProvider = StateProvider((ref) { + final primaryColorPreset = ref.read(immichThemePresetProvider); + final useSystemColor = ref.watch(dynamicThemeSettingProvider); + final useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider); + final ImmichTheme? dynamicTheme = DynamicTheme.theme; + final currentTheme = (useSystemColor && dynamicTheme != null) + ? dynamicTheme + : primaryColorPreset.themeOfPreset; + + return useColorfulInterface + ? currentTheme + : decolorizeSurfaces(theme: currentTheme); +}); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 14d800a4ef..c3fde894d5 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,4 +1,4 @@ -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/theme/color_scheme.dart similarity index 80% rename from mobile/lib/constants/immich_colors.dart rename to mobile/lib/theme/color_scheme.dart index 847887de8c..c01b7cfa5a 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/theme/color_scheme.dart @@ -1,29 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; -enum ImmichColorPreset { - indigo, - deepPurple, - pink, - red, - orange, - yellow, - lime, - green, - cyan, - slateGray -} - -const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo; -const String defaultColorPresetName = "indigo"; - -const Color immichBrandColorLight = Color(0xFF4150AF); -const Color immichBrandColorDark = Color(0xFFACCBFA); -const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); -const Color red400 = Color(0xFFEF5350); -const Color grey200 = Color(0xFFEEEEEE); - -final Map _themePresetsMap = { +final Map _themePresets = { ImmichColorPreset.indigo: ImmichTheme( light: ColorScheme.fromSeed( seedColor: immichBrandColorLight, @@ -110,5 +89,5 @@ final Map _themePresetsMap = { }; extension ImmichColorModeExtension on ImmichColorPreset { - ImmichTheme getTheme() => _themePresetsMap[this]!; + ImmichTheme get themeOfPreset => _themePresets[this]!; } diff --git a/mobile/lib/theme/dynamic_theme.dart b/mobile/lib/theme/dynamic_theme.dart new file mode 100644 index 0000000000..39d6b6ee45 --- /dev/null +++ b/mobile/lib/theme/dynamic_theme.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:dynamic_color/dynamic_color.dart'; + +import 'package:immich_mobile/theme/theme_data.dart'; + +abstract final class DynamicTheme { + DynamicTheme._(); + + static ImmichTheme? _theme; + // Method to fetch dynamic system colors + static Future fetchSystemPalette() async { + try { + final corePalette = await DynamicColorPlugin.getCorePalette(); + if (corePalette != null) { + final primaryColor = corePalette.toColorScheme().primary; + debugPrint('dynamic_color: Core palette detected.'); + + // Some palettes do not generate surface container colors accurately, + // so we regenerate all colors using the primary color + _theme = ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + dark: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + ); + } + } catch (error) { + debugPrint('dynamic_color: Failed to obtain core palette: $error'); + } + } + + static ImmichTheme? get theme => _theme; + static bool get isAvailable => _theme != null; +} diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/theme/theme_data.dart similarity index 58% rename from mobile/lib/utils/immich_app_theme.dart rename to mobile/lib/theme/theme_data.dart index 2ca4fe3aff..de96e12c5d 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/theme/theme_data.dart @@ -1,11 +1,7 @@ -import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; + import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; class ImmichTheme { final ColorScheme light; @@ -14,104 +10,166 @@ class ImmichTheme { const ImmichTheme({required this.light, required this.dark}); } -ImmichTheme? _immichDynamicTheme; -bool get isDynamicThemeAvailable => _immichDynamicTheme != null; +ThemeData getThemeData({ + required ColorScheme colorScheme, + required Locale locale, +}) { + final isDark = colorScheme.brightness == Brightness.dark; -final immichThemeModeProvider = StateProvider((ref) { - var themeMode = ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.themeMode); - - debugPrint("Current themeMode $themeMode"); - - if (themeMode == "light") { - return ThemeMode.light; - } else if (themeMode == "dark") { - return ThemeMode.dark; - } else { - return ThemeMode.system; - } -}); - -final immichThemePresetProvider = StateProvider((ref) { - var appSettingsProvider = ref.watch(appSettingsServiceProvider); - var primaryColorName = - appSettingsProvider.getSetting(AppSettingsEnum.primaryColor); - - debugPrint("Current theme preset $primaryColorName"); - - try { - return ImmichColorPreset.values - .firstWhere((e) => e.name == primaryColorName); - } catch (e) { - debugPrint( - "Theme preset $primaryColorName not found. Applying default preset.", - ); - appSettingsProvider.setSetting( - AppSettingsEnum.primaryColor, - defaultColorPresetName, - ); - return defaultColorPreset; - } -}); - -final dynamicThemeSettingProvider = StateProvider((ref) { - return ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.dynamicTheme); -}); - -final colorfulInterfaceSettingProvider = StateProvider((ref) { - return ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.colorfulInterface); -}); - -// Provider for current selected theme -final immichThemeProvider = StateProvider((ref) { - var primaryColor = ref.read(immichThemePresetProvider); - var useSystemColor = ref.watch(dynamicThemeSettingProvider); - var useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider); - - var currentTheme = (useSystemColor && _immichDynamicTheme != null) - ? _immichDynamicTheme! - : primaryColor.getTheme(); - - return useColorfulInterface - ? currentTheme - : _decolorizeSurfaces(theme: currentTheme); -}); - -// Method to fetch dynamic system colors -Future fetchSystemPalette() async { - try { - final corePalette = await DynamicColorPlugin.getCorePalette(); - if (corePalette != null) { - final primaryColor = corePalette.toColorScheme().primary; - debugPrint('dynamic_color: Core palette detected.'); - - // Some palettes do not generate surface container colors accurately, - // so we regenerate all colors using the primary color - _immichDynamicTheme = ImmichTheme( - light: ColorScheme.fromSeed( - seedColor: primaryColor, - brightness: Brightness.light, + return ThemeData( + useMaterial3: true, + brightness: colorScheme.brightness, + colorScheme: colorScheme, + primaryColor: colorScheme.primary, + hintColor: colorScheme.onSurfaceSecondary, + focusColor: colorScheme.primary, + scaffoldBackgroundColor: colorScheme.surface, + splashColor: colorScheme.primary.withOpacity(0.1), + highlightColor: colorScheme.primary.withOpacity(0.1), + dialogBackgroundColor: colorScheme.surfaceContainer, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colorScheme.surfaceContainer, + ), + fontFamily: _getFontFamilyFromLocale(locale), + snackBarTheme: SnackBarThemeData( + contentTextStyle: TextStyle( + fontFamily: _getFontFamilyFromLocale(locale), + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + backgroundColor: colorScheme.surfaceContainerHighest, + ), + appBarTheme: AppBarTheme( + titleTextStyle: TextStyle( + color: colorScheme.primary, + fontFamily: _getFontFamilyFromLocale(locale), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + foregroundColor: colorScheme.primary, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + ), + textTheme: const TextTheme( + displayLarge: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + ), + displayMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + displaySmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + titleSmall: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + titleMedium: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + titleLarge: TextStyle( + fontSize: 26.0, + fontWeight: FontWeight.bold, + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: isDark ? Colors.black87 : Colors.white, + ), + ), + chipTheme: const ChipThemeData( + side: BorderSide.none, + ), + sliderTheme: const SliderThemeData( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + trackHeight: 2.0, + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + ), + popupMenuTheme: const PopupMenuThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + labelTextStyle: const WidgetStatePropertyAll( + TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, ), - dark: ColorScheme.fromSeed( - seedColor: primaryColor, - brightness: Brightness.dark, + ), + ), + inputDecorationTheme: InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.primary, ), - ); - } - } catch (e) { - debugPrint('dynamic_color: Failed to obtain core palette.'); - } + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + labelStyle: TextStyle( + color: colorScheme.primary, + ), + hintStyle: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + ), + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: colorScheme.primary, + ), + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: const MenuStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.primary, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + labelStyle: TextStyle( + color: colorScheme.primary, + ), + hintStyle: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + ), + ), + ), + ); } // This method replaces all surface shades in ImmichTheme to a static ones // as we are creating the colorscheme through seedColor the default surfaces are // tinted with primary color -ImmichTheme _decolorizeSurfaces({ +ImmichTheme decolorizeSurfaces({ required ImmichTheme theme, }) { return ImmichTheme( @@ -146,167 +204,10 @@ ImmichTheme _decolorizeSurfaces({ ); } -String? getFontFamilyFromLocale(Locale locale) { +String? _getFontFamilyFromLocale(Locale locale) { if (localesNotSupportedByOverpass.contains(locale)) { // Let Flutter use the default font return null; } return 'Overpass'; } - -ThemeData getThemeData({ - required ColorScheme colorScheme, - required Locale locale, -}) { - var isDark = colorScheme.brightness == Brightness.dark; - var primaryColor = colorScheme.primary; - - return ThemeData( - useMaterial3: true, - brightness: colorScheme.brightness, - colorScheme: colorScheme, - primaryColor: primaryColor, - hintColor: colorScheme.onSurfaceSecondary, - focusColor: primaryColor, - scaffoldBackgroundColor: colorScheme.surface, - splashColor: primaryColor.withOpacity(0.1), - highlightColor: primaryColor.withOpacity(0.1), - dialogBackgroundColor: colorScheme.surfaceContainer, - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: colorScheme.surfaceContainer, - ), - fontFamily: getFontFamilyFromLocale(locale), - snackBarTheme: SnackBarThemeData( - contentTextStyle: TextStyle( - fontFamily: getFontFamilyFromLocale(locale), - color: primaryColor, - fontWeight: FontWeight.bold, - ), - backgroundColor: colorScheme.surfaceContainerHighest, - ), - appBarTheme: AppBarTheme( - titleTextStyle: TextStyle( - color: primaryColor, - fontFamily: getFontFamilyFromLocale(locale), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - backgroundColor: - isDark ? colorScheme.surfaceContainer : colorScheme.surface, - foregroundColor: primaryColor, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - ), - displayMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - displaySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - titleSmall: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - titleMedium: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - titleLarge: TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: primaryColor, - foregroundColor: isDark ? Colors.black87 : Colors.white, - ), - ), - chipTheme: const ChipThemeData( - side: BorderSide.none, - ), - sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - ), - popupMenuTheme: const PopupMenuThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - ), - navigationBarTheme: NavigationBarThemeData( - backgroundColor: - isDark ? colorScheme.surfaceContainer : colorScheme.surface, - labelTextStyle: const WidgetStatePropertyAll( - TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - inputDecorationTheme: InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: primaryColor, - ), - borderRadius: const BorderRadius.all(Radius.circular(15)), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - borderRadius: const BorderRadius.all(Radius.circular(15)), - ), - labelStyle: TextStyle( - color: primaryColor, - ), - hintStyle: const TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, - ), - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: primaryColor, - ), - dropdownMenuTheme: DropdownMenuThemeData( - menuStyle: MenuStyle( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), - ), - inputDecorationTheme: InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: primaryColor, - ), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: colorScheme.outlineVariant, - ), - borderRadius: const BorderRadius.all(Radius.circular(15)), - ), - labelStyle: TextStyle( - color: primaryColor, - ), - hintStyle: const TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, - ), - ), - ), - ); -} diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart index e4dd355554..f5479ab86e 100644 --- a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart +++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; class MotionPhotoButton extends ConsumerWidget { diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index b1f70b8686..4d0e7aa17f 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; diff --git a/mobile/lib/widgets/backup/error_chip.dart b/mobile/lib/widgets/backup/error_chip.dart index 4bbc040d4d..4df3e50f64 100644 --- a/mobile/lib/widgets/backup/error_chip.dart +++ b/mobile/lib/widgets/backup/error_chip.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/error_chip_text.dart'; diff --git a/mobile/lib/widgets/backup/error_chip_text.dart b/mobile/lib/widgets/backup/error_chip_text.dart index 94148da176..540e136722 100644 --- a/mobile/lib/widgets/backup/error_chip_text.dart +++ b/mobile/lib/widgets/backup/error_chip_text.dart @@ -1,7 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; class BackupErrorChipText extends ConsumerWidget { diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart index 68a2146bfb..65425f9e78 100644 --- a/mobile/lib/widgets/map/map_theme_override.dart +++ b/mobile/lib/widgets/map/map_theme_override.dart @@ -3,21 +3,22 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; -import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; /// Overrides the theme below the widget tree to use the theme data based on the /// map settings instead of the one from the app settings -class MapThemeOveride extends StatefulHookConsumerWidget { +class MapThemeOverride extends StatefulHookConsumerWidget { final ThemeMode? themeMode; final Widget Function(AsyncValue style) mapBuilder; - const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key}); + const MapThemeOverride({required this.mapBuilder, this.themeMode, super.key}); @override - ConsumerState createState() => _MapThemeOverideState(); + ConsumerState createState() => _MapThemeOverrideState(); } -class _MapThemeOverideState extends ConsumerState +class _MapThemeOverrideState extends ConsumerState with WidgetsBindingObserver { late ThemeMode _theme; bool _isDarkTheme = false; diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index d02c016791..b856f09787 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -62,7 +62,7 @@ class MapThumbnail extends HookConsumerWidget { } } - return MapThemeOveride( + return MapThemeOverride( themeMode: themeMode, mapBuilder: (style) => SizedBox( height: height, diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart index 1c7cd1f207..119407ccad 100644 --- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -2,12 +2,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/immich_app_theme.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/theme/color_scheme.dart'; +import 'package:immich_mobile/theme/dynamic_theme.dart'; class PrimaryColorSetting extends HookConsumerWidget { const PrimaryColorSetting({ @@ -124,7 +126,7 @@ class PrimaryColorSetting extends HookConsumerWidget { style: context.textTheme.titleLarge, ), ), - if (isDynamicThemeAvailable) + if (DynamicTheme.isAvailable) Container( padding: const EdgeInsets.symmetric(horizontal: 20), margin: const EdgeInsets.only(top: 10), @@ -153,16 +155,16 @@ class PrimaryColorSetting extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 20), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, - children: ImmichColorPreset.values.map((themePreset) { - var theme = themePreset.getTheme(); + children: ImmichColorPreset.values.map((preset) { + final theme = preset.themeOfPreset; return GestureDetector( - onTap: () => onPrimaryColorChange(themePreset), + onTap: () => onPrimaryColorChange(preset), child: buildPrimaryColorTile( topColor: theme.light.primary, bottomColor: theme.dark.primary, tileSize: tileSize, - showSelector: currentPreset.value == themePreset && + showSelector: currentPreset.value == preset && !systemPrimaryColorSetting.value, ), ); diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index 3e1f388e84..b9ba7aa7b7 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -3,12 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:immich_mobile/utils/immich_app_theme.dart'; class ThemeSetting extends HookConsumerWidget { const ThemeSetting({ diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index c21f9bf166..bd000c8715 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -35,7 +35,7 @@ void main() { (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); @@ -53,7 +53,7 @@ void main() { testWidgets("Return error when style is not fetched", (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); @@ -73,7 +73,7 @@ void main() { (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); @@ -94,7 +94,7 @@ void main() { testWidgets("Return dark theme style when system is dark", (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); @@ -118,7 +118,7 @@ void main() { (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); @@ -142,7 +142,7 @@ void main() { (tester) async { AsyncValue? mapStyle; await tester.pumpConsumerWidget( - MapThemeOveride( + MapThemeOverride( mapBuilder: (AsyncValue style) { mapStyle = style; return const Text("Mock"); From e40c7c51ee0cec49a7d1cd33f714401e0308f97f Mon Sep 17 00:00:00 2001 From: Travis Menghini Date: Wed, 11 Dec 2024 10:31:11 -0600 Subject: [PATCH 538/599] feat(web): allow tags to be applied in bulk on search, personID, and memory-viewer pages (#14368) * Allow Tags to be applied in bulk on search page * Added Tags Action To PersonID Page * Fixed Formatting Issues * Added Tags Option to Memory-Viewer --- web/src/lib/components/memory-page/memory-viewer.svelte | 6 ++++++ .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 6 ++++++ .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 72723670e6..65ef47c9ca 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -46,6 +46,8 @@ import { tweened } from 'svelte/motion'; import { derived as storeDerived } from 'svelte/store'; import { fade } from 'svelte/transition'; + import { preferences, user } from '$lib/stores/user.store'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; type MemoryIndex = { memoryIndex: number; @@ -221,6 +223,7 @@ $effect(() => { handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); }); + let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); + {#if $preferences.tags.enabled && isAllUserOwned} + + {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 48e194dda4..143a19dd5c 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -58,6 +58,8 @@ import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import { preferences, user } from '$lib/stores/user.store'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; interface Props { data: PageData; @@ -337,6 +339,7 @@ let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); + let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); {#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} @@ -391,6 +394,9 @@ $assetStore.removeAssets(assetIds)} /> + {#if $preferences.tags.enabled && isAllUserOwned} + + {/if} $assetStore.removeAssets(assetIds)} /> diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index c800dd7014..7372f05e77 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -44,6 +44,8 @@ import { t } from 'svelte-i18n'; import { onMount, tick } from 'svelte'; import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; + import { preferences, user } from '$lib/stores/user.store'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; const MAX_ASSET_COUNT = 5000; let { isViewing: showAssetViewer } = assetViewingStore; @@ -229,6 +231,7 @@ function getObjectKeys(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[]; } + let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); @@ -250,6 +253,9 @@ + {#if $preferences.tags.enabled && isAllUserOwned} + + {/if}
    From 3053d84e49e2e0a8f7f54c51e5d236d1cdc2a8a2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:23:20 -0500 Subject: [PATCH 539/599] fix(mobile): not being able to zoom into live photos (#14608) fix live photo zoom --- .../lib/pages/common/gallery_viewer.page.dart | 6 ++-- .../common/native_video_viewer.page.dart | 35 +++---------------- .../asset_grid/immich_asset_grid_view.dart | 2 ++ 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 2ea446ea71..5f77f28d8e 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -61,6 +61,7 @@ class GalleryViewerPage extends HookConsumerWidget { final localPosition = useRef(null); final currentIndex = useValueNotifier(initialIndex); final loadAsset = renderList.loadAsset; + final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); Future precacheNextImage(int index) async { if (!context.mounted) { @@ -249,7 +250,6 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = false; var newAsset = loadAsset(index); final stackId = newAsset.stackId; if (stackId != null && currentIndex.value == index) { @@ -260,7 +260,7 @@ class GalleryViewerPage extends HookConsumerWidget { } } - if (newAsset.isImage && !newAsset.isMotionPhoto) { + if (newAsset.isImage && !isPlayingMotionVideo) { return buildImage(context, newAsset); } return buildVideo(context, newAsset); @@ -275,7 +275,7 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( - key: const ValueKey('gallery'), + key: ValueKey(isPlayingMotionVideo), scaleStateChangedCallback: (state) { final asset = ref.read(currentAssetProvider); if (asset == null) { diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 536c7f6303..33acad0fdf 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -40,7 +40,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { final controller = useState(null); final lastVideoPosition = useRef(-1); final isBuffering = useRef(false); - final showMotionVideo = useState(false); // When a video is opened through the timeline, `isCurrent` will immediately be true. // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. @@ -50,30 +49,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { final isCurrent = currentAsset.value == asset; // Used to show the placeholder during hero animations for remote videos to avoid a stutter - final isVisible = - useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto); + final isVisible = useState(Platform.isIOS && asset.isLocal); final log = Logger('NativeVideoViewerPage'); - ref.listen(isPlayingMotionVideoProvider, (_, value) async { - final videoController = controller.value; - if (!asset.isMotionPhoto || videoController == null || !context.mounted) { - return; - } - - showMotionVideo.value = value; - try { - if (value) { - await videoController.seekTo(0); - await videoController.play(); - } else { - await videoController.pause(); - } - } catch (error) { - log.severe('Error toggling motion video: $error'); - } - }); - Future createSource() async { if (!context.mounted) { return null; @@ -81,7 +60,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { try { final local = asset.local; - if (local != null && !asset.isMotionPhoto) { + if (local != null) { final file = await local.file; if (file == null) { throw Exception('No file found for the video'); @@ -204,9 +183,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; try { - if (asset.isVideo || showMotionVideo.value) { - await videoController.play(); - } + await videoController.play(); await videoController.setVolume(0.9); } catch (error) { log.severe('Error playing video: $error'); @@ -268,8 +245,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { return; } - if (showMotionVideo.value && - videoController.playbackInfo?.status == PlaybackStatus.stopped && + if (videoController.playbackInfo?.status == PlaybackStatus.stopped && !ref .read(appSettingsServiceProvider) .getSetting(AppSettingsEnum.loopVideo)) { @@ -388,8 +364,7 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (aspectRatio.value != null) Visibility.maintain( key: ValueKey(asset), - visible: - (asset.isVideo || showMotionVideo.value) && isVisible.value, + visible: isVisible.value, child: Center( key: ValueKey(asset), child: AspectRatio( diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 5670aa388f..c38e61a473 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; @@ -206,6 +207,7 @@ class ImmichAssetGridViewState extends ConsumerState { heroOffset: widget.heroOffset, onAssetTap: (asset) { ref.read(currentAssetProvider.notifier).set(asset); + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; if (asset.isVideo) { ref.read(showControlsProvider.notifier).show = false; } From 71b48b11e697d5f7e029a294d8614f76416a455f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:24:13 -0600 Subject: [PATCH 540/599] chore(deps): update dependency pytest-cov to v6 (#13925) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 128 +++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 59 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 229db1303b..bb7cd95149 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -575,63 +575,73 @@ test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"] [[package]] name = "coverage" -version = "7.4.0" +version = "7.6.4" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, - {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, - {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, - {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, - {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, + {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, + {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, + {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, + {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, + {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, + {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, + {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, + {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, + {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, + {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, + {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, + {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, + {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, + {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, + {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, + {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, + {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, + {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, + {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, + {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, + {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, + {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, + {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, + {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, + {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, + {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, + {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, + {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, + {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, + {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, + {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, + {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, + {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, + {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, + {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, + {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, + {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, + {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, ] [package.dependencies] @@ -2683,17 +2693,17 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "5.0.0" +version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, ] [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} +coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] From 0c037536422ecae950eb208b6612f558785d6e8b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 11 Dec 2024 14:51:56 -0600 Subject: [PATCH 541/599] fix(server): fix getByDayOfYear query (#14655) * fix(server): fix getByDayOfYear query * generate sql --- server/src/queries/asset.repository.sql | 15 ++++++++++++++- server/src/repositories/asset.repository.ts | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f4b1b2fea1..4694cd20fc 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -71,7 +71,20 @@ FROM INNER JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" WHERE ( - "files"."type" = $1 + "entity"."ownerId" IN ($1) + AND "entity"."isVisible" = true + AND "entity"."isArchived" = false + AND EXTRACT( + DAY + FROM + "entity"."localDateTime" AT TIME ZONE 'UTC' + ) = $2 + AND EXTRACT( + MONTH + FROM + "entity"."localDateTime" AT TIME ZONE 'UTC' + ) = $3 + AND "files"."type" = $4 AND EXTRACT( YEAR FROM diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b3066a37bc..33d1e2457e 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -92,7 +92,7 @@ export class AssetRepository implements IAssetRepository { ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') .innerJoinAndSelect('entity.files', 'files') - .where('files.type = :type', { type: AssetFileType.THUMBNAIL }) + .andWhere('files.type = :type', { type: AssetFileType.THUMBNAIL }) .andWhere( `EXTRACT(YEAR FROM CURRENT_DATE AT TIME ZONE 'UTC') - EXTRACT(YEAR FROM entity.localDateTime AT TIME ZONE 'UTC') > 0`, ) From c52f1bae81aae8d0c86b2cf3f9384eb92e39ab36 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 21:15:03 +0000 Subject: [PATCH 542/599] chore: version v1.122.3 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 03b8061efb..137565a22d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.35", + "version": "2.2.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.35", + "version": "2.2.36", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index b58825b2b9..9b3a417385 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.35", + "version": "2.2.36", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 7ba9125c03..960e00caa9 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.122.3", + "url": "https://v1.122.3.archive.immich.app" + }, { "label": "v1.122.2", "url": "https://v1.122.2.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 011e6b2fdd..ec20557358 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.122.2", + "version": "1.122.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.35", + "version": "2.2.36", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index a47b4bbae9..12316e910c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.122.2", + "version": "1.122.3", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index cff2a432b3..0f8186c41f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.122.2" +version = "1.122.3" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 4fc00ce6c7..f3c30770e1 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 170, - "android.injected.version.name" => "1.122.2", + "android.injected.version.code" => 171, + "android.injected.version.name" => "1.122.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d7604b4283..0574a5e78f 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.122.2" + version_number: "1.122.3" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d9e10bc316..6bacbc7423 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.122.2 +- API version: 1.122.3 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 39621a953e..c1cf25d008 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.122.2+170 +version: 1.122.3+171 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 706d6a28ee..3afda881cd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7436,7 +7436,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.122.2", + "version": "1.122.3", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index c0d5d329d1..fa7d83feb5 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 22f480e68d..7a812e87fb 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 68f44d7bed..7770f0c578 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.122.2 + * 1.122.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 4ad00c90f7..6d898a9735 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.122.2", + "version": "1.122.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index a7005deafa..385cbccd3d 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.122.2", + "version": "1.122.3", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index cab21cd4dc..f3c1f4b12e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.122.2", + "version": "1.122.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 2bd429cc1a..c35344c12f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.122.2", + "version": "1.122.3", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 11be85feb3016fd9007e8390034e0d24c1a4480b Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:48:50 +0100 Subject: [PATCH 543/599] fix(web): live photo link action (#14668) --- web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 7e233fcd17..b76143142e 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -49,7 +49,7 @@ const isLivePhotoCandidate = selection.length === 2 && selection.some((asset) => asset.type === AssetTypeEnum.Image) && - selection.some((asset) => asset.type === AssetTypeEnum.Image); + selection.some((asset) => asset.type === AssetTypeEnum.Video); isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); }); From 58d63d9f1ce3a27a2562ca9f427393d7039e8c68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:10:51 -0600 Subject: [PATCH 544/599] chore(deps): update grafana/grafana docker tag to v11.4.0 (#14633) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index d58b20ef76..8521390079 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -103,7 +103,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.3.1-ubuntu@sha256:7ca40d20250157abd70a907a93617a70c9b0ad9d7e59e8e6b5c8140781350d6a + image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c volumes: - grafana-data:/var/lib/grafana From 59d6af54c7a22a66c2212bed85dc8f05a7150b11 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:12:44 -0600 Subject: [PATCH 545/599] chore(deps): update node.js to v22.12.0 (#14650) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/.nvmrc b/cli/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/cli/package.json b/cli/package.json index 9b3a417385..86f54cc342 100644 --- a/cli/package.json +++ b/cli/package.json @@ -67,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/docs/package.json b/docs/package.json index 6b0595a7b0..498a0c4d7c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -56,6 +56,6 @@ "node": ">=20" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/e2e/package.json b/e2e/package.json index 12316e910c..9e9ce3b362 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -53,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 7a812e87fb..c05d21b696 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } diff --git a/server/.nvmrc b/server/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/server/package.json b/server/package.json index 385cbccd3d..f57c0e557b 100644 --- a/server/package.json +++ b/server/package.json @@ -139,6 +139,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } diff --git a/web/.nvmrc b/web/.nvmrc index 7af24b7ddb..1d9b7831ba 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -22.11.0 +22.12.0 diff --git a/web/package.json b/web/package.json index c35344c12f..84158674a8 100644 --- a/web/package.json +++ b/web/package.json @@ -87,6 +87,6 @@ "thumbhash": "^0.1.1" }, "volta": { - "node": "22.11.0" + "node": "22.12.0" } } From 6abe696d0bee48f82dd5d33416e1db414f212749 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:13:42 +0100 Subject: [PATCH 546/599] fix(web): allow minimizing upload panel (#14663) --- .../components/shared-components/upload-panel.svelte | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 7dd6d25596..2381b5a423 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -17,19 +17,9 @@ let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore; - const autoHide = () => { - if (!$isUploading && showDetail) { - showDetail = false; - } - - if ($isUploading && !showDetail) { - showDetail = true; - } - }; - $effect(() => { if ($isUploading) { - autoHide(); + showDetail = true; } }); From 40a0bf6ad57a4524ad28de28795769218efa66ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:14:34 -0600 Subject: [PATCH 547/599] chore(deps): update terraform cloudflare to v4.48.0 (#14669) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 160c4f7ba5..00222921f1 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.46.0" - constraints = "4.46.0" + version = "4.48.0" + constraints = "4.48.0" hashes = [ - "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=", - "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=", - "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=", - "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=", - "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=", - "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=", - "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=", - "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=", - "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=", - "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=", - "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=", - "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=", - "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=", - "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=", - "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343", - "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7", - "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463", - "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79", - "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418", - "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a", + "h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=", + "h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=", + "h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=", + "h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=", + "h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=", + "h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=", + "h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=", + "h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=", + "h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=", + "h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=", + "h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=", + "h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=", + "h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=", + "h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=", + "zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c", + "zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997", + "zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b", + "zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb", + "zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153", + "zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8", + "zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f", + "zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04", + "zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937", + "zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10", - "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6", - "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd", - "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a", - "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa", - "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8", - "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf", - "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3", + "zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c", + "zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532", + "zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f", + "zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index f06c083bb0..c5397ea410 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.46.0" + version = "4.48.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 160c4f7ba5..00222921f1 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.46.0" - constraints = "4.46.0" + version = "4.48.0" + constraints = "4.48.0" hashes = [ - "h1:3U4N3bbMacXTAdyaEwT305kETMETh1jZmGApmN6gdyE=", - "h1:3fhZhGNgtS9ugcZ2CIH6kk8LzN6yPxqOdkDUZqkP3+w=", - "h1:JWluJxBRSr8GVUhWVv83xse9SmbpwCLctCDddMXUnVk=", - "h1:KDHwakGt+3iBKXaoALCCAolPaJgpEHbkh3BfjnpuqoM=", - "h1:QFFZshAvwr9L5TQmsNQC6/sDqokk5pjbP8Ae4BQqMLQ=", - "h1:Qdi+vXwzDNii7ytSaOQtnlqhjZ3ZlRoUkFoi6CD2COI=", - "h1:TPcJXcVb/+C91hUuu8CEn98QUoNgLtnHfd4sgAOV+5k=", - "h1:WDy5wiNroXaCnw+r8rJnCP+J1RVsm2Qu3AOZ/iV4lLo=", - "h1:hMuL+dwHj3JbePqYcDrn/ZQN9R0WzeJX0AIDJ02Iteo=", - "h1:hQKCaUEARzJKbFt1CePP06E/+CiHWe/H6lc1AwK7y6w=", - "h1:l4DQ3WXmSzR/GBel3m2CRKWtaziVjBoxvUgL63t1GK0=", - "h1:nN9uVSLyrb/DjfZl6rPtCq5j0TX+6WypzNDexdzCQ08=", - "h1:rAX7njl6lKT9XIKMk6pLjVi7u/42wafRolWWgMHMkI0=", - "h1:t2IQYNu8YNykqYlEB+TTX+XpUd5z2flwGw8km9UgbnQ=", - "zh:2ee426ef3389022db0026792fdc4f2980dcf2600e31adf5a31b4bddfa8d68343", - "zh:2f993edb23df55dc1c18150fa187d80aa7d87e6439698ee34b6a6aad23ac2dd7", - "zh:3d6601333975e55979b1b454e50ff9a482ce4e0269dd6c72a50202163a8f4463", - "zh:4e5f48dce22f7a6d618018d65d1d443bb718defa23f514d5c6385860541fbe79", - "zh:5ebf5aea960fc30de381ffd6db20876d249673cf938fe67f1dfb6b9caa1db418", - "zh:80ed3fb901141f53b4b56ddb7eea5f2e0c0830d501387539d2c2b8e0cc7e587a", + "h1:0IKUOR32xEI1suS5QCOjfxjQ2mRd058btXk8hVnaOJ4=", + "h1:3YG6vu/bFPcYOeLdSUZhiAWiWKaFlOAR34z2o8cbE9k=", + "h1:FvGy06/i9AMtVkSIUnCrXNv5xF6jqBqMH8oPVLyeeAg=", + "h1:GXH7nIF0ocMqebbA41+fSGIYfM+VAM/PvTe7fJr8UrQ=", + "h1:H0ll0ph4404vFE868W3qJ3zhOyy4jbXrOMtdkViEZsU=", + "h1:SX42e3k73IcFcrQlZ2e/Veqt2tvCMy6fwlo5yNUktCE=", + "h1:Uu/gjBc99GefdPdSrlBwU75DWU0ZcwGcrd3ZFyTeL0s=", + "h1:VZw0uN41PWRmNlhg7Ze0Eh7cdoklX1oZbfNAXNYnU1I=", + "h1:cMdV7ql6PsFa4qtb0EoZSctvTaTqV7yplBSDwcLRCLc=", + "h1:ePGvSurmlqOCkD761vkhRmz7bsK36/EnIvx2Xy8TdXo=", + "h1:fOYufF+1bzw2N3aHLpkLB6E8VbZ4ysXDODYQOlwhwd4=", + "h1:qe8RbnWq0T4xhqjn9QcbO6YW5YDx47P+eJ0NUMIfwCc=", + "h1:tRD2av6PafHDP/b9jDQsG5/aX+lHeKxpbIEHYYLBVUc=", + "h1:zyl6Gvx/CFpwYW8pFFDesfO8Lxv+a6CopyAsIMhp54s=", + "zh:04c0a49c2b23140b2f21cfd0d52f9798d70d3bdae3831613e156aabe519bbc6c", + "zh:185f21b4834ba63e8df1f84aa34639d8a7e126429a4007bb5f9ad82f2602a997", + "zh:234724f52cb4c0c3f7313d3b2697caef26d921d134f26ae14801e7afac522f7b", + "zh:38a56fcd1b3e40706af995611c977816543b53f1e55fe2720944aae2b6828fcb", + "zh:419938f5430fc78eff933470aefbf94a460a478f867cf7761a3dea177b4eb153", + "zh:4b46d92bfde1deab7de7ba1a6bbf4ba7c711e4fd925341ddf09d4cc28dae03d8", + "zh:537acd4a31c752f1bae305ba7190f60b71ad1a459f22d464f3f914336c9e919f", + "zh:5ff36b005aad07697dd0b30d4f0c35dbcdc30dc52b41722552060792fa87ce04", + "zh:635c5ee419daea098060f794d9d7d999275301181e49562c4e4c08f043076937", + "zh:859277c330d61f91abe9e799389467ca11b77131bf34bedbef52f8da68b2bb49", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:9aeae8b3be4a577ced46987fd9159262c5b4c54a510f66592fbcdb40fef55b10", - "zh:a0479ef2d308c4a7894f1fe77467cd07e04c7b40d281088f4f204af1bdf94ac6", - "zh:a2bdc0c25130665af0b9559942b9813a1ba4889513e7185d4abc9c02e9bb99bd", - "zh:b10be9755fe80395ced6f0bbda38b8c8681714cf1eca1d895be239c75c2ffc2a", - "zh:ba3d55e722d9f48646574ce7c448f0084fe21fa884b5f8b6d6146a82a99c4baa", - "zh:ec1fd0ecaedc787a77d5342b51ae8dea8362a67f1e19123f6521a0e8e012d9e8", - "zh:ed49590e69faef14550179f965b4451b31415b8f6be6d33427ad48f65c76b6cf", - "zh:f4baa3a2dac719ad20dcfa525bc3f737ad95650b8d0de0c648dc9a87f993b2c3", + "zh:927dfdb8d9aef37ead03fceaa29e87ba076a3dd24e19b6cefdbb0efe9987ff8c", + "zh:bbf2226f07f6b1e721877328e69ded4b64f9c196634d2e2429e3cfabbe41e532", + "zh:daeed873d6f38604232b46ee4a5830c85d195b967f8dbcafe2fcffa98daf9c5f", + "zh:f8f2fc4646c1ba44085612fa7f4dbb7cbcead43b4e661f2b98ddfb4f68afc758", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index f06c083bb0..c5397ea410 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.46.0" + version = "4.48.0" } } } From 39732f3371e8a92e46c22b332b7c73013510d5f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:28:27 +0000 Subject: [PATCH 548/599] chore(deps): update base-image to v20241210 (major) (#14670) chore(deps): update base-image to v20241210 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 37b80ff1ee..9b510b72cc 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20241119@sha256:fef1bead6a594ebd6fa54712c3dc4db050173657738db0c21bb91b00f8b56320 AS dev +FROM ghcr.io/immich-app/base-server-dev:20241210@sha256:35c28404b508fc0741fffb39a9de17e3a6acdff0b623bb7b6cdf4a427462a0e2 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20241119@sha256:0ab6c3d0d41924fba45f92c383bcf405abda338602d1140d151963bbbb088759 +FROM ghcr.io/immich-app/base-server-prod:20241210@sha256:076d002070385bc6dc7454ef9419f44341c074bcfc49be5deddbdb4108ae0060 WORKDIR /usr/src/app ENV NODE_ENV=production \ From bccf2f60b2caa072e0bd7ab6f88b8d65a774a290 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Dec 2024 10:59:14 -0600 Subject: [PATCH 549/599] fix(web): upload info panel covers timeline navigation bar (#14651) --- web/src/lib/components/shared-components/upload-panel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 2381b5a423..0eb7d1655c 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -48,7 +48,7 @@ } uploadAssetsStore.reset(); }} - class="fixed bottom-6 right-6 z-[10000]" + class="fixed bottom-6 right-16 z-[10000]" > {#if showDetail}
    Date: Fri, 13 Dec 2024 18:13:38 +0100 Subject: [PATCH 550/599] fix(server): fixed email footer image stretched #14617 (#14671) --- server/src/emails/components/footer.template.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/src/emails/components/footer.template.tsx b/server/src/emails/components/footer.template.tsx index 7c41a7196d..c84246bf87 100644 --- a/server/src/emails/components/footer.template.tsx +++ b/server/src/emails/components/footer.template.tsx @@ -5,12 +5,14 @@ export const ImmichFooter = () => ( <> - - - +
    + + + +
    -
    +
    Immich From b5022d80d6cdb4a27e33970522868d6ea58e3744 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 14 Dec 2024 19:30:33 +0100 Subject: [PATCH 551/599] refactor(web): asset interaction (#14662) * refactor(web): asset interaction * feedback --- .../components/album-page/album-viewer.svelte | 19 ++-- .../memory-page/memory-viewer.svelte | 29 +++---- .../actions/select-all-assets.svelte | 10 +-- .../photos-page/asset-date-group.svelte | 26 +++--- .../components/photos-page/asset-grid.svelte | 77 ++++++++--------- .../individual-shared-viewer.svelte | 17 ++-- .../gallery-viewer/gallery-viewer.svelte | 62 +++++++------ web/src/lib/stores/asset-interaction.store.ts | 86 ------------------- .../stores/asset-interaction.svelte.spec.ts | 40 +++++++++ .../lib/stores/asset-interaction.svelte.ts | 66 ++++++++++++++ web/src/lib/utils/asset-utils.ts | 10 +-- .../[[assetId=id]]/+page.svelte | 77 +++++++++-------- .../[[assetId=id]]/+page.svelte | 22 ++--- .../[[assetId=id]]/+page.svelte | 26 +++--- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 12 ++- .../[[assetId=id]]/+page.svelte | 44 +++++----- .../(user)/photos/[[assetId=id]]/+page.svelte | 52 +++++------ .../[[assetId=id]]/+page.svelte | 33 ++++--- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 22 ++--- 21 files changed, 375 insertions(+), 367 deletions(-) delete mode 100644 web/src/lib/stores/asset-interaction.store.ts create mode 100644 web/src/lib/stores/asset-interaction.svelte.spec.ts create mode 100644 web/src/lib/stores/asset-interaction.svelte.ts diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1dc43c5b61..02544e3e07 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -4,7 +4,6 @@ import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -20,6 +19,7 @@ import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -34,8 +34,7 @@ let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ albumId: album.id, order: album.order }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -52,8 +51,8 @@ use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: () => { - if (!$showAssetViewer && $isMultiSelectState) { - cancelMultiselect(assetInteractionStore); + if (!$showAssetViewer && assetInteraction.selectionActive) { + cancelMultiselect(assetInteraction); } }, }} @@ -61,13 +60,13 @@ />
    - {#if $isMultiSelectState} + {#if assetInteraction.selectionActive} assetInteractionStore.clearMultiselect()} + assets={assetInteraction.selectedAssets} + clearSelect={() => assetInteraction.clearMultiselect()} > - + {#if sharedLink.allowDownload} {/if} @@ -102,7 +101,7 @@
    - +

    (0, { duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), }); @@ -130,7 +129,7 @@ const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handleEscape = async () => goto(AppRoute.PHOTOS); - const handleSelectAll = () => assetInteractionStore.selectAssets(current?.memory.assets || []); + const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); const handleAction = async (action: 'reset' | 'pause' | 'play') => { switch (action) { case 'play': { @@ -212,10 +211,6 @@ current = loadFromParams($memories, target); }); - let isMultiSelectionMode = $derived($selectedAssets.size > 0); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - $effect(() => { handlePromiseError(handleProgress($progressBarController)); }); @@ -223,7 +218,6 @@ $effect(() => { handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); }); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); -{#if isMultiSelectionMode} +{#if assetInteraction.selectionActive}
    - cancelMultiselect(assetInteractionStore)}> + cancelMultiselect(assetInteraction)} + > @@ -249,14 +246,14 @@ - + - - {#if $preferences.tags.enabled && isAllUserOwned} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} @@ -490,7 +487,7 @@ onPrevious={handlePreviousAsset} assets={current.memory.assets} {viewport} - {assetInteractionStore} + {assetInteraction} />

    diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte index cc27f3ebbe..9e7c2b9163 100644 --- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte +++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte @@ -1,24 +1,24 @@ diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index b2780cc1a0..586491ef47 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -2,7 +2,6 @@ import { intersectionObserver } from '$lib/actions/intersection-observer'; import Icon from '$lib/components/elements/icon.svelte'; import Skeleton from '$lib/components/photos-page/skeleton.svelte'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store'; import { navigate } from '$lib/utils/navigation'; import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; @@ -13,6 +12,7 @@ import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { TUNABLES } from '$lib/utils/tunables'; import { generateId } from '$lib/utils/generate-id'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; export let element: HTMLElement | undefined = undefined; export let isSelectionMode = false; @@ -25,7 +25,7 @@ export let renderThumbsAtTopMargin: string | undefined = undefined; export let assetStore: AssetStore; export let bucket: AssetBucket; - export let assetInteractionStore: AssetInteractionStore; + export let assetInteraction: AssetInteraction; export let onScrollTarget: ScrollTargetListener | undefined = undefined; export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; @@ -43,13 +43,11 @@ /* TODO figure out a way to calculate this*/ const TITLE_HEIGHT = 51; - const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; - let isMouseOverGroup = false; let hoveredDateGroup = ''; const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { - if (isSelectionMode || $isMultiSelectState) { + if (isSelectionMode || assetInteraction.selectionActive) { assetSelectHandler(asset, assets, groupTitle); return; } @@ -69,13 +67,15 @@ onSelectAssets(asset); // Check if all assets are selected in a group to toggle the group selection's icon - let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; + let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => + assetInteraction.selectedAssets.has(asset), + ).length; // if all assets are selected in a group, add the group to selected group if (selectedAssetsInGroupCount == assetsInDateGroup.length) { - assetInteractionStore.addGroupToMultiselectGroup(groupTitle); + assetInteraction.addGroupToMultiselectGroup(groupTitle); } else { - assetInteractionStore.removeGroupFromMultiselectGroup(groupTitle); + assetInteraction.removeGroupFromMultiselectGroup(groupTitle); } }; @@ -83,7 +83,7 @@ // Show multi select icon on hover on date group hoveredDateGroup = groupTitle; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { onSelectAssetCandidates(asset); } }; @@ -151,14 +151,14 @@ class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
    handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} > - {#if $selectedGroup.has(dateGroup.groupTitle)} + {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} {:else} @@ -212,8 +212,8 @@ onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} + selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} disabled={$assetStore.albumAssets.has(asset.id)} thumbnailWidth={box.width} thumbnailHeight={box.height} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 5055cdcf4b..cc64c6f02b 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -3,7 +3,6 @@ import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AppRoute, AssetAction } from '$lib/constants'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store'; import { locale, showDeleteModal } from '$lib/stores/preferences.store'; @@ -37,6 +36,7 @@ import type { UpdatePayload } from 'vite'; import { generateId } from '$lib/utils/generate-id'; import { isTimelineScrolling } from '$lib/stores/timeline.store'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { isSelectionMode?: boolean; @@ -46,7 +46,7 @@ additionally, update the page location/url with the asset as the asset-grid is scrolled */ enableRouting: boolean; assetStore: AssetStore; - assetInteractionStore: AssetInteractionStore; + assetInteraction: AssetInteraction; removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; withStacked?: boolean; showArchiveIcon?: boolean; @@ -64,7 +64,7 @@ singleSelect = false, enableRouting, assetStore = $bindable(), - assetInteractionStore, + assetInteraction, removeAction = null, withStacked = false, showArchiveIcon = false, @@ -78,8 +78,6 @@ }: Props = $props(); let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; - const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = - assetInteractionStore; const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); @@ -437,11 +435,11 @@ (assetIds) => $assetStore.removeAssets(assetIds), idsSelectedAssets, ); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; const onDelete = () => { - const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); + const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { isShowDeleteConfirmation = true; @@ -459,7 +457,7 @@ }; const onStackAssets = async () => { - const ids = await stackAssets(Array.from($selectedAssets)); + const ids = await stackAssets(assetInteraction.selectedAssetsArray); if (ids) { $assetStore.removeAssets(ids); onEscape(); @@ -467,7 +465,7 @@ }; const toggleArchive = async () => { - const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); + const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { $assetStore.removeAssets(ids); deselectAllAssets(); @@ -482,7 +480,7 @@ const handleSelectAsset = (asset: AssetResponseDto) => { if (!$assetStore.albumAssets.has(asset.id)) { - assetInteractionStore.selectAsset(asset); + assetInteraction.selectAsset(asset); } }; @@ -573,7 +571,7 @@ let shiftKeyIsDown = $state(false); const deselectAllAssets = () => { - cancelMultiselect(assetInteractionStore); + cancelMultiselect(assetInteraction); }; const onKeyDown = (event: KeyboardEvent) => { @@ -606,13 +604,13 @@ }; const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => { - if ($selectedGroup.has(group)) { - assetInteractionStore.removeGroupFromMultiselectGroup(group); + if (assetInteraction.selectedGroup.has(group)) { + assetInteraction.removeGroupFromMultiselectGroup(group); for (const asset of assets) { - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } } else { - assetInteractionStore.addGroupToMultiselectGroup(group); + assetInteraction.addGroupToMultiselectGroup(group); for (const asset of assets) { handleSelectAsset(asset); } @@ -631,26 +629,26 @@ return; } - const rangeSelection = $assetSelectionCandidates.size > 0; - const deselect = $selectedAssets.has(asset); + const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; + const deselect = assetInteraction.selectedAssets.has(asset); // Select/deselect already loaded assets if (deselect) { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.removeAssetFromMultiselectGroup(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.removeAssetFromMultiselectGroup(candidate); } - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { - for (const candidate of $assetSelectionCandidates || []) { + for (const candidate of assetInteraction.assetSelectionCandidates) { handleSelectAsset(candidate); } handleSelectAsset(asset); } - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); - if ($assetSelectionStart && rangeSelection) { - let startBucketIndex = $assetStore.getBucketIndexByAssetId($assetSelectionStart.id); + if (assetInteraction.assetSelectionStart && rangeSelection) { + let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id); if (startBucketIndex === null || endBucketIndex === null) { @@ -667,7 +665,7 @@ await $assetStore.loadBucket(bucket.bucketDate); for (const asset of bucket.assets) { if (deselect) { - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { handleSelectAsset(asset); } @@ -682,16 +680,16 @@ const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); for (const dateGroup of assetsGroupByDate) { const dateGroupTitle = formatGroupTitle(dateGroup.date); - if (dateGroup.assets.every((a) => $selectedAssets.has(a))) { - assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); + if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) { + assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); } else { - assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); + assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); } } } } - assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); + assetInteraction.setAssetSelectionStart(deselect ? null : asset); }; const selectAssetCandidates = (endAsset: AssetResponseDto) => { @@ -699,7 +697,7 @@ return; } - const startAsset = $assetSelectionStart; + const startAsset = assetInteraction.assetSelectionStart; if (!startAsset) { return; } @@ -711,11 +709,11 @@ [start, end] = [end, start]; } - assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { - if ($isMultiSelectState && shiftKeyIsDown) { + if (assetInteraction.selectionActive && shiftKeyIsDown) { e.preventDefault(); } }; @@ -724,12 +722,11 @@ }); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0); - let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); $effect(() => { if (isEmpty) { - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); } }); @@ -760,12 +757,12 @@ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, - { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, + { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteraction) }, { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, ]; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { shortcuts.push( { shortcut: { key: 'Delete' }, onShortcut: onDelete }, { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete }, @@ -781,13 +778,13 @@ $effect(() => { if (!lastAssetMouseEvent) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); $effect(() => { if (!shiftKeyIsDown) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); @@ -889,7 +886,7 @@ {withStacked} {showArchiveIcon} {assetStore} - {assetInteractionStore} + {assetInteraction} {isSelectionMode} {singleSelect} {onScrollTarget} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 5d625cef9d..ebc4b49001 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -15,11 +15,11 @@ import ControlAppBar from '../shared-components/control-app-bar.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import type { Viewport } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -29,12 +29,10 @@ let { sharedLink = $bindable(), isOwned }: Props = $props(); const viewport: Viewport = $state({ width: 0, height: 0 }); - const assetInteractionStore = createAssetInteractionStore(); - const { selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); let innerWidth: number = $state(0); let assets = $derived(sharedLink.assets); - let isMultiSelectionMode = $derived($selectedAssets.size > 0); dragAndDropFilesStore.subscribe((value) => { if (value.isDragging && value.files.length > 0) { @@ -73,15 +71,18 @@ }; const handleSelectAll = () => { - assetInteractionStore.selectAssets(assets); + assetInteraction.selectAssets(assets); };
    - {#if isMultiSelectionMode} - cancelMultiselect(assetInteractionStore)}> + {#if assetInteraction.selectionActive} + cancelMultiselect(assetInteraction)} + > {#if sharedLink?.allowDownload} @@ -112,6 +113,6 @@ {/if}
    - +
    diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index eda340e7e2..8f8a067a90 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -5,7 +5,6 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import type { Viewport } from '$lib/stores/assets.store'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { deleteAssets } from '$lib/utils/actions'; @@ -22,10 +21,11 @@ import Portal from '../portal/portal.svelte'; import { handlePromiseError } from '$lib/utils'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; + import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { assets: AssetResponseDto[]; - assetInteractionStore: AssetInteractionStore; + assetInteraction: AssetInteraction; disableAssetSelect?: boolean; showArchiveIcon?: boolean; viewport: Viewport; @@ -38,7 +38,7 @@ let { assets = $bindable(), - assetInteractionStore = $bindable(), + assetInteraction, disableAssetSelect = false, showArchiveIcon = false, viewport, @@ -51,11 +51,8 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; - const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; - let showShortcuts = $state(false); let currentViewAssetIndex = 0; - let isMultiSelectionMode = $derived($selectedAssets.size > 0); let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: AssetResponseDto | null = $state(null); @@ -66,11 +63,11 @@ }; const selectAllAssets = () => { - assetInteractionStore.selectAssets(assets); + assetInteraction.selectAssets(assets); }; const deselectAllAssets = () => { - cancelMultiselect(assetInteractionStore); + cancelMultiselect(assetInteraction); }; const onKeyDown = (event: KeyboardEvent) => { @@ -91,23 +88,23 @@ if (!asset) { return; } - const deselect = $selectedAssets.has(asset); + const deselect = assetInteraction.selectedAssets.has(asset); // Select/deselect already loaded assets if (deselect) { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.removeAssetFromMultiselectGroup(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.removeAssetFromMultiselectGroup(candidate); } - assetInteractionStore.removeAssetFromMultiselectGroup(asset); + assetInteraction.removeAssetFromMultiselectGroup(asset); } else { - for (const candidate of $assetSelectionCandidates || []) { - assetInteractionStore.selectAsset(candidate); + for (const candidate of assetInteraction.assetSelectionCandidates) { + assetInteraction.selectAsset(candidate); } - assetInteractionStore.selectAsset(asset); + assetInteraction.selectAsset(asset); } - assetInteractionStore.clearAssetSelectionCandidates(); - assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); + assetInteraction.clearAssetSelectionCandidates(); + assetInteraction.setAssetSelectionStart(deselect ? null : asset); }; const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { @@ -122,7 +119,7 @@ return; } - const startAsset = $assetSelectionStart; + const startAsset = assetInteraction.assetSelectionStart; if (!startAsset) { return; } @@ -134,17 +131,17 @@ [start, end] = [end, start]; } - assetInteractionStore.setAssetSelectionCandidates(assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { - if ($isMultiSelectState && shiftKeyIsDown) { + if (assetInteraction.selectionActive && shiftKeyIsDown) { e.preventDefault(); } }; const onDelete = () => { - const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); + const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { isShowDeleteConfirmation = true; @@ -168,11 +165,11 @@ (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), idsSelectedAssets, ); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; const toggleArchive = async () => { - const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); + const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); @@ -191,7 +188,7 @@ { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, ]; - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { shortcuts.push( { shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets }, { shortcut: { key: 'Delete' }, onShortcut: onDelete }, @@ -266,14 +263,13 @@ }; const assetMouseEventHandler = (asset: AssetResponseDto | null) => { - if ($isMultiSelectState) { + if (assetInteraction.selectionActive) { handleSelectAssetCandidates(asset); } }; let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); - let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let geometry = $derived( (() => { @@ -297,13 +293,13 @@ $effect(() => { if (!lastAssetMouseEvent) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); $effect(() => { if (!shiftKeyIsDown) { - assetInteractionStore.clearAssetSelectionCandidates(); + assetInteraction.clearAssetSelectionCandidates(); } }); @@ -318,7 +314,7 @@ {#if isShowDeleteConfirmation} (isShowDeleteConfirmation = false)} onConfirm={() => handlePromiseError(trashOrDelete(true))} /> @@ -340,7 +336,7 @@ { - if (isMultiSelectionMode) { + if (assetInteraction.selectionActive) { handleSelectAssets(asset); return; } @@ -351,8 +347,8 @@ onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} {showArchiveIcon} {asset} - selected={$selectedAssets.has(asset)} - selectionCandidate={$assetSelectionCandidates.has(asset)} + selected={assetInteraction.selectedAssets.has(asset)} + selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} thumbnailWidth={geometry.boxes[i].width} thumbnailHeight={geometry.boxes[i].height} /> diff --git a/web/src/lib/stores/asset-interaction.store.ts b/web/src/lib/stores/asset-interaction.store.ts deleted file mode 100644 index f7db5382b0..0000000000 --- a/web/src/lib/stores/asset-interaction.store.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { AssetResponseDto } from '@immich/sdk'; -import { derived, readonly, writable } from 'svelte/store'; - -export type AssetInteractionStore = ReturnType; - -export function createAssetInteractionStore() { - const selectedAssets = writable(new Set()); - const selectedGroup = writable(new Set()); - const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0); - - // Candidates for the range selection. This set includes only loaded assets, so it improves highlight - // performance. From the user's perspective, range is highlighted almost immediately - const assetSelectionCandidates = writable(new Set()); - // The beginning of the selection range - const assetSelectionStart = writable(null); - - const selectAsset = (asset: AssetResponseDto) => { - selectedAssets.update(($selectedAssets) => $selectedAssets.add(asset)); - }; - - const selectAssets = (assets: AssetResponseDto[]) => { - selectedAssets.update(($selectedAssets) => { - for (const asset of assets) { - $selectedAssets.add(asset); - } - return $selectedAssets; - }); - }; - - const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => { - selectedAssets.update(($selectedAssets) => { - $selectedAssets.delete(asset); - return $selectedAssets; - }); - }; - - const addGroupToMultiselectGroup = (group: string) => { - selectedGroup.update(($selectedGroup) => $selectedGroup.add(group)); - }; - - const removeGroupFromMultiselectGroup = (group: string) => { - selectedGroup.update(($selectedGroup) => { - $selectedGroup.delete(group); - return $selectedGroup; - }); - }; - - const setAssetSelectionStart = (asset: AssetResponseDto | null) => { - assetSelectionStart.set(asset); - }; - - const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => { - assetSelectionCandidates.set(new Set(assets)); - }; - - const clearAssetSelectionCandidates = () => { - assetSelectionCandidates.set(new Set()); - }; - - const clearMultiselect = () => { - // Multi-selection - selectedAssets.set(new Set()); - selectedGroup.set(new Set()); - - // Range selection - assetSelectionCandidates.set(new Set()); - assetSelectionStart.set(null); - }; - - return { - selectAsset, - selectAssets, - removeAssetFromMultiselectGroup, - addGroupToMultiselectGroup, - removeGroupFromMultiselectGroup, - setAssetSelectionCandidates, - clearAssetSelectionCandidates, - setAssetSelectionStart, - clearMultiselect, - isMultiSelectState: readonly(isMultiSelectStoreState), - selectedAssets: readonly(selectedAssets), - selectedGroup: readonly(selectedGroup), - assetSelectionCandidates: readonly(assetSelectionCandidates), - assetSelectionStart: readonly(assetSelectionStart), - }; -} diff --git a/web/src/lib/stores/asset-interaction.svelte.spec.ts b/web/src/lib/stores/asset-interaction.svelte.spec.ts new file mode 100644 index 0000000000..5d3043b37c --- /dev/null +++ b/web/src/lib/stores/asset-interaction.svelte.spec.ts @@ -0,0 +1,40 @@ +import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; +import { resetSavedUser, user } from '$lib/stores/user.store'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; + +describe('AssetInteraction', () => { + let assetInteraction: AssetInteraction; + + beforeEach(() => { + assetInteraction = new AssetInteraction(); + }); + + it('calculates derived values from selection', () => { + assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true })); + assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false })); + + expect(assetInteraction.selectionActive).toBe(true); + expect(assetInteraction.isAllTrashed).toBe(false); + expect(assetInteraction.isAllArchived).toBe(false); + expect(assetInteraction.isAllFavorite).toBe(true); + }); + + it('updates isAllUserOwned when the active user changes', () => { + const [user1, user2] = userAdminFactory.buildList(2); + assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id })); + + const cleanup = $effect.root(() => { + expect(assetInteraction.isAllUserOwned).toBe(false); + + user.set(user1); + expect(assetInteraction.isAllUserOwned).toBe(true); + + user.set(user2); + expect(assetInteraction.isAllUserOwned).toBe(false); + }); + + cleanup(); + resetSavedUser(); + }); +}); diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts new file mode 100644 index 0000000000..4397c7f71f --- /dev/null +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -0,0 +1,66 @@ +import { user } from '$lib/stores/user.store'; +import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; +import { SvelteSet } from 'svelte/reactivity'; +import { fromStore } from 'svelte/store'; + +export class AssetInteraction { + readonly selectedAssets = new SvelteSet(); + readonly selectedGroup = new SvelteSet(); + assetSelectionCandidates = $state(new SvelteSet()); + assetSelectionStart = $state(null); + + selectionActive = $derived(this.selectedAssets.size > 0); + selectedAssetsArray = $derived([...this.selectedAssets]); + + private user = fromStore(user); + private userId = $derived(this.user.current?.id); + + isAllTrashed = $derived(this.selectedAssetsArray.every((asset) => asset.isTrashed)); + isAllArchived = $derived(this.selectedAssetsArray.every((asset) => asset.isArchived)); + isAllFavorite = $derived(this.selectedAssetsArray.every((asset) => asset.isFavorite)); + isAllUserOwned = $derived(this.selectedAssetsArray.every((asset) => asset.ownerId === this.userId)); + + selectAsset(asset: AssetResponseDto) { + this.selectedAssets.add(asset); + } + + selectAssets(assets: AssetResponseDto[]) { + for (const asset of assets) { + this.selectedAssets.add(asset); + } + } + + removeAssetFromMultiselectGroup(asset: AssetResponseDto) { + this.selectedAssets.delete(asset); + } + + addGroupToMultiselectGroup(group: string) { + this.selectedGroup.add(group); + } + + removeGroupFromMultiselectGroup(group: string) { + this.selectedGroup.delete(group); + } + + setAssetSelectionStart(asset: AssetResponseDto | null) { + this.assetSelectionStart = asset; + } + + setAssetSelectionCandidates(assets: AssetResponseDto[]) { + this.assetSelectionCandidates = new SvelteSet(assets); + } + + clearAssetSelectionCandidates() { + this.assetSelectionCandidates.clear(); + } + + clearMultiselect() { + // Multi-selection + this.selectedAssets.clear(); + this.selectedGroup.clear(); + + // Range selection + this.assetSelectionCandidates.clear(); + this.assetSelectionStart = null; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 37041ecbc4..5b06a66597 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; -import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; +import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; @@ -460,7 +460,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S } }; -export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { +export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => { if (get(isSelectingAllAssets)) { // Selection is already ongoing return; @@ -474,7 +474,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt if (!get(isSelectingAllAssets)) { break; // Cancelled } - assetInteractionStore.selectAssets(bucket.assets); + assetInteraction.selectAssets(bucket.assets); // We use setTimeout to allow the UI to update. Otherwise, this may // cause a long delay between the start of 'select all' and the @@ -489,9 +489,9 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt } }; -export const cancelMultiselect = (assetInteractionStore: AssetInteractionStore) => { +export const cancelMultiselect = (assetInteraction: AssetInteraction) => { isSelectingAllAssets.set(false); - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); }; export const toggleArchive = async (asset: AssetResponseDto) => { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c63d8e1a3..0f6c62a5fa 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -35,7 +35,6 @@ import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -87,6 +86,7 @@ import { onDestroy } from 'svelte'; import { confirmAlbumDelete } from '$lib/utils/album-utils'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -107,11 +107,8 @@ let reactions: ActivityResponseDto[] = $state([]); let albumOrder: AssetOrder | undefined = $state(data.album.order); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - const timelineInteractionStore = createAssetInteractionStore(); - const { selectedAssets: timelineSelected } = timelineInteractionStore; + const assetInteraction = new AssetInteraction(); + const timelineInteraction = new AssetInteraction(); afterNavigate(({ from }) => { let url: string | undefined = from?.url?.pathname; @@ -234,8 +231,8 @@ if ($showAssetViewer) { return; } - if ($isMultiSelectState) { - cancelMultiselect(assetInteractionStore); + if (assetInteraction.selectionActive) { + cancelMultiselect(assetInteraction); return; } await goto(backUrl); @@ -245,9 +242,8 @@ const refreshAlbum = async () => { album = await getAlbumInfo({ id: album.id, withoutAssets: true }); }; - const handleAddAssets = async () => { - const assetIds = [...$timelineSelected].map((asset) => asset.id); + const assetIds = timelineInteraction.selectedAssetsArray.map((asset) => asset.id); try { const results = await addAssetsToAlbum({ @@ -263,7 +259,7 @@ await refreshAlbum(); - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); @@ -284,13 +280,13 @@ }; const handleCloseSelectAssets = async () => { - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); - timelineInteractionStore.clearMultiselect(); + timelineInteraction.clearMultiselect(); await setModeToView(); }; @@ -359,16 +355,16 @@ } viewMode = AlbumPageViewMode.VIEW; - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); await updateThumbnail(assetId); }; const updateThumbnailUsingCurrentSelection = async () => { - if ($selectedAssets.size === 1) { - const assetId = [...$selectedAssets][0].id; - assetInteractionStore.clearMultiselect(); - await updateThumbnail(assetId); + if (assetInteraction.selectedAssets.size === 1) { + const [firstAsset] = assetInteraction.selectedAssets; + assetInteraction.clearMultiselect(); + await updateThumbnail(firstAsset.id); } }; @@ -410,9 +406,6 @@ let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); let isOwned = $derived($user.id == album.ownerId); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); let showActivityStatus = $derived( album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), @@ -433,40 +426,50 @@
    - {#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> + {#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > - + - {#if isAllUserOwned} - assetStore.triggerUpdate()} /> + {#if assetInteraction.isAllUserOwned} + assetStore.triggerUpdate()} + /> {/if} - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} - {#if $selectedAssets.size === 1} + {#if assetInteraction.selectedAssets.size === 1} updateThumbnailUsingCurrentSelection()} /> {/if} - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} + /> {/if} - {#if $preferences.tags.enabled && isAllUserOwned} + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} - {#if isOwned || isAllUserOwned} + {#if isOwned || assetInteraction.isAllUserOwned} {/if} - {#if isAllUserOwned} + {#if assetInteraction.isAllUserOwned} {/if} @@ -540,10 +543,10 @@ {#snippet leading()}

    - {#if $timelineSelected.size === 0} + {#if !timelineInteraction.selectionActive} {$t('add_to_album')} {:else} - {$t('selected_count', { values: { count: $timelineSelected.size } })} + {$t('selected_count', { values: { count: timelineInteraction.selectedAssets.size } })} {/if}

    {/snippet} @@ -556,7 +559,7 @@ > {$t('select_from_computer')} - {/snippet} @@ -579,7 +582,7 @@ {:else} @@ -587,7 +590,7 @@ enableRouting={true} {album} {assetStore} - {assetInteractionStore} + {assetInteraction} isShared={album.albumUsers.length > 0} isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3402dff960..5301364ccb 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -12,12 +12,12 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -26,26 +26,26 @@ let { data }: Props = $props(); const assetStore = new AssetStore({ isArchived: true }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); + const assetInteraction = new AssetInteraction(); onDestroy(() => { assetStore.destroy(); }); -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > assetStore.removeAssets(assetIds)} /> - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> assetStore.removeAssets(assetIds)} /> @@ -53,8 +53,8 @@ {/if} - - + + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6635eda6e9..33a03292cd 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -14,7 +14,6 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; @@ -22,6 +21,7 @@ import { onDestroy } from 'svelte'; import { preferences } from '$lib/stores/user.store'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -30,10 +30,7 @@ let { data }: Props = $props(); const assetStore = new AssetStore({ isFavorite: true }); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); + const assetInteraction = new AssetInteraction(); onDestroy(() => { assetStore.destroy(); @@ -41,11 +38,14 @@ -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > assetStore.removeAssets(assetIds)} /> - + @@ -54,7 +54,11 @@ - assetStore.removeAssets(assetIds)} /> + assetStore.removeAssets(assetIds)} + /> {#if $preferences.tags.enabled} {/if} @@ -63,8 +67,8 @@ {/if} - - + + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 065b28c674..5119905652 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -3,7 +3,6 @@ import { page } from '$app/stores'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; @@ -17,6 +16,7 @@ import type { PageData } from './$types'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -31,7 +31,7 @@ let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); - const assetInteractionStore = createAssetInteractionStore(); + const assetInteraction = new AssetInteraction(); onMount(async () => { await foldersStore.fetchUniquePaths(); @@ -80,7 +80,7 @@
    { - assetInteractionStore.clearMultiselect(); assetStore.destroy(); });
    - {#if $isMultiSelectState} - + {#if assetInteraction.selectionActive} + @@ -50,5 +48,5 @@ {/snippet} {/if} - +
    diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 143a19dd5c..6788c678ed 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -27,7 +27,6 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { websocketEvents } from '$lib/stores/websocket'; @@ -58,8 +57,9 @@ import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import { preferences, user } from '$lib/stores/user.store'; + import { preferences } from '$lib/stores/user.store'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -78,8 +78,7 @@ handlePromiseError(assetStore.updateOptions(assetStoreOptions)); }); - const assetInteractionStore = createAssetInteractionStore(); - const { selectedAssets, isMultiSelectState } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); let isEditingName = $state(false); @@ -123,8 +122,8 @@ if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) { return; } - if ($isMultiSelectState) { - assetInteractionStore.clearMultiselect(); + if (assetInteraction.selectionActive) { + assetInteraction.clearMultiselect(); return; } else { await goto(previousRoute); @@ -149,8 +148,8 @@ }); const handleUnmerge = () => { - $assetStore.removeAssets([...$selectedAssets].map((a) => a.id)); - assetInteractionStore.clearMultiselect(); + $assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); + assetInteraction.clearMultiselect(); viewMode = PersonPageViewMode.VIEW_ASSETS; }; @@ -194,7 +193,7 @@ handleError(error, $t('errors.unable_to_set_feature_photo')); } - assetInteractionStore.clearMultiselect(); + assetInteraction.clearMultiselect(); viewMode = PersonPageViewMode.VIEW_ASSETS; }; @@ -336,15 +335,11 @@ handlePromiseError(updateAssetCount()); } }); - - let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); {#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} a.id)} + assetIds={assetInteraction.selectedAssetsArray.map((a) => a.id)} personAssets={person} onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} onConfirm={handleUnmerge} @@ -375,15 +370,18 @@ {/if}
    - {#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> + {#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> - $assetStore.removeAssets(assetIds)} /> - {#if $preferences.tags.enabled && isAllUserOwned} + $assetStore.removeAssets(assetIds)} + /> + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} $assetStore.removeAssets(assetIds)} /> @@ -453,7 +455,7 @@ { - const selection = [...$selectedAssets]; - isAllOwned = selection.every((asset) => asset.ownerId === $user.id); - isAllFavorite = selection.every((asset) => asset.isFavorite); - isAssetStackSelected = selection.length === 1 && !!selection[0].stack; - const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; + let selectedAssets = $derived(assetInteraction.selectedAssetsArray); + let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); + let isLinkActionAvailable = $derived.by(() => { + const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; const isLivePhotoCandidate = - selection.length === 2 && - selection.some((asset) => asset.type === AssetTypeEnum.Image) && - selection.some((asset) => asset.type === AssetTypeEnum.Video); - isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); - }); + selectedAssets.length === 2 && + selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && + selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); + return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); + }); const handleEscape = () => { if ($showAssetViewer) { return; } - if ($isMultiSelectState) { - assetInteractionStore.clearMultiselect(); + if (assetInteraction.selectionActive) { + assetInteraction.clearMultiselect(); return; } }; @@ -78,22 +70,22 @@ }); -{#if $isMultiSelectState} +{#if assetInteraction.selectionActive} assetInteractionStore.clearMultiselect()} + assets={assetInteraction.selectedAssets} + clearSelect={() => assetInteraction.clearMultiselect()} > - + - assetStore.triggerUpdate()} /> + assetStore.triggerUpdate()} /> - {#if $selectedAssets.size > 1 || isAssetStackSelected} + {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} assetStore.removeAssets(assetIds)} @@ -103,7 +95,7 @@ {#if isLinkActionAvailable} @@ -121,11 +113,11 @@ {/if} - + ; - - let isMultiSelectionMode = $derived($selectedAssets.size > 0); - let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); - let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY)); onMount(() => { @@ -86,8 +81,8 @@ return; } - if (isMultiSelectionMode) { - $selectedAssets = new Set(); + if (assetInteraction.selectionActive) { + assetInteraction.selectedAssets.clear(); return; } if (!$preventRaceConditionSearchBar) { @@ -131,7 +126,7 @@ searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); }; const handleSelectAll = () => { - assetInteractionStore.selectAssets(searchResultAssets); + assetInteraction.selectAssets(searchResultAssets); }; async function onSearchQueryUpdate() { @@ -231,29 +226,31 @@ function getObjectKeys(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[]; } - let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
    - {#if isMultiSelectionMode} + {#if assetInteraction.selectionActive}
    - cancelMultiselect(assetInteractionStore)}> + cancelMultiselect(assetInteraction)} + > - + - - {#if $preferences.tags.enabled && isAllUserOwned} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} @@ -333,7 +330,7 @@ {#if searchResultAssets.length > 0} { return Object.fromEntries(tags.map((tag) => [tag.value, tag])); @@ -198,7 +198,7 @@
    {#if tag} - + {#snippet empty()} {/snippet} diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 8803ea38c8..7f97d3772b 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,7 +15,6 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; @@ -26,6 +25,7 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { data: PageData; @@ -39,8 +39,7 @@ const options = { isTrashed: true }; const assetStore = new AssetStore(options); - const assetInteractionStore = createAssetInteractionStore(); - const { isMultiSelectState, selectedAssets } = assetInteractionStore; + const assetInteraction = new AssetInteraction(); const handleEmptyTrash = async () => { const isConfirmed = await dialogController.show({ @@ -93,25 +92,28 @@ }); -{#if $isMultiSelectState} - assetInteractionStore.clearMultiselect()}> - +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > + assetStore.removeAssets(assetIds)} /> assetStore.removeAssets(assetIds)} /> {/if} {#if $featureFlags.loaded && $featureFlags.trash} - + {#snippet buttons()}
    - +
    {$t('restore_all')}
    - handleEmptyTrash()} disabled={$isMultiSelectState}> + handleEmptyTrash()} disabled={assetInteraction.selectionActive}>
    {$t('empty_trash')} @@ -120,7 +122,7 @@
    {/snippet} - +

    {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}

    From cc111a1fcb1987ecba8dc025a1177de0f8f394cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 14 Dec 2024 13:43:31 -0600 Subject: [PATCH 552/599] fix(deps): update dependency analyzer to v7 (#14673) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/immich_lint/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml index 9d1a3c26b3..5d871b03e6 100644 --- a/mobile/immich_lint/pubspec.yaml +++ b/mobile/immich_lint/pubspec.yaml @@ -5,7 +5,7 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - analyzer: ^6.8.0 + analyzer: ^7.0.0 analyzer_plugin: ^0.11.3 custom_lint_builder: ^0.6.4 glob: ^2.1.2 From dd9feeec45d272dbe2c2100e40e0f465d960a964 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 14 Dec 2024 12:53:15 -0700 Subject: [PATCH 553/599] chore(mobile): remove screen auto-dimming (#14699) --- .../pages/backup/backup_controller.page.dart | 191 +++++++----------- 1 file changed, 69 insertions(+), 122 deletions(-) diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index d8baecf808..6783f7b54a 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -1,11 +1,9 @@ -import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -31,8 +29,6 @@ class BackupControllerPage extends HookConsumerWidget { BackUpState backupState = ref.watch(backupProvider); final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty; final didGetBackupInfo = useState(false); - final isScreenDarkened = useState(false); - final darkenScreenTimer = useRef(null); bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; @@ -43,25 +39,6 @@ class BackupControllerPage extends HookConsumerWidget { ? false : true; - void startScreenDarkenTimer() { - darkenScreenTimer.value = Timer(const Duration(seconds: 30), () { - isScreenDarkened.value = true; - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - }); - } - - void stopScreenDarkenTimer() { - darkenScreenTimer.value?.cancel(); - isScreenDarkened.value = false; - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: [ - SystemUiOverlay.top, - SystemUiOverlay.bottom, - ], - ); - } - useEffect( () { // Update the background settings information just to make sure we @@ -77,8 +54,6 @@ class BackupControllerPage extends HookConsumerWidget { return () { WakelockPlus.disable(); - darkenScreenTimer.value?.cancel(); - isScreenDarkened.value = false; }; }, [], @@ -99,10 +74,8 @@ class BackupControllerPage extends HookConsumerWidget { useEffect( () { if (backupState.backupProgress == BackUpProgressEnum.inProgress) { - startScreenDarkenTimer(); WakelockPlus.enable(); } else { - stopScreenDarkenTimer(); WakelockPlus.disable(); } @@ -297,103 +270,77 @@ class BackupControllerPage extends HookConsumerWidget { ); } - return GestureDetector( - onTap: () { - if (isScreenDarkened.value) { - stopScreenDarkenTimer(); - } - if (backupState.backupProgress == BackUpProgressEnum.inProgress) { - startScreenDarkenTimer(); - } - }, - child: AnimatedOpacity( - opacity: isScreenDarkened.value ? 0.1 : 1.0, - duration: const Duration(seconds: 1), - child: Scaffold( - appBar: AppBar( - elevation: 0, - title: const Text( - "backup_controller_page_backup", - ).tr(), - leading: IconButton( - onPressed: () { - ref.watch(websocketProvider.notifier).listenUploadEvent(); - context.maybePop(true); - }, - splashRadius: 24, - icon: const Icon( - Icons.arrow_back_ios_rounded, - ), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: IconButton( - onPressed: () => - context.pushRoute(const BackupOptionsRoute()), - splashRadius: 24, - icon: const Icon( - Icons.settings_outlined, - ), - ), - ), - ], - ), - body: Stack( - children: [ - Padding( - padding: - const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), - child: ListView( - // crossAxisAlignment: CrossAxisAlignment.start, - children: hasAnyAlbum - ? [ - buildFolderSelectionTile(), - BackupInfoCard( - title: "backup_controller_page_total".tr(), - subtitle: "backup_controller_page_total_sub".tr(), - info: ref - .watch(backupProvider) - .availableAlbums - .isEmpty - ? "..." - : "${backupState.allUniqueAssets.length}", - ), - BackupInfoCard( - title: "backup_controller_page_backup".tr(), - subtitle: "backup_controller_page_backup_sub".tr(), - info: ref - .watch(backupProvider) - .availableAlbums - .isEmpty - ? "..." - : "${backupState.selectedAlbumsBackupAssetsIds.length}", - ), - BackupInfoCard( - title: "backup_controller_page_remainder".tr(), - subtitle: - "backup_controller_page_remainder_sub".tr(), - info: ref - .watch(backupProvider) - .availableAlbums - .isEmpty - ? "..." - : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", - ), - const Divider(), - const CurrentUploadingAssetInfoBox(), - if (!hasExclusiveAccess) buildBackgroundBackupInfo(), - buildBackupButton(), - ] - : [ - buildFolderSelectionTile(), - if (!didGetBackupInfo.value) buildLoadingIndicator(), - ], - ), - ), - ], + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text( + "backup_controller_page_backup", + ).tr(), + leading: IconButton( + onPressed: () { + ref.watch(websocketProvider.notifier).listenUploadEvent(); + context.maybePop(true); + }, + splashRadius: 24, + icon: const Icon( + Icons.arrow_back_ios_rounded, ), ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () => context.pushRoute(const BackupOptionsRoute()), + splashRadius: 24, + icon: const Icon( + Icons.settings_outlined, + ), + ), + ), + ], + ), + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), + child: ListView( + // crossAxisAlignment: CrossAxisAlignment.start, + children: hasAnyAlbum + ? [ + buildFolderSelectionTile(), + BackupInfoCard( + title: "backup_controller_page_total".tr(), + subtitle: "backup_controller_page_total_sub".tr(), + info: ref.watch(backupProvider).availableAlbums.isEmpty + ? "..." + : "${backupState.allUniqueAssets.length}", + ), + BackupInfoCard( + title: "backup_controller_page_backup".tr(), + subtitle: "backup_controller_page_backup_sub".tr(), + info: ref.watch(backupProvider).availableAlbums.isEmpty + ? "..." + : "${backupState.selectedAlbumsBackupAssetsIds.length}", + ), + BackupInfoCard( + title: "backup_controller_page_remainder".tr(), + subtitle: "backup_controller_page_remainder_sub".tr(), + info: ref.watch(backupProvider).availableAlbums.isEmpty + ? "..." + : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", + ), + const Divider(), + const CurrentUploadingAssetInfoBox(), + if (!hasExclusiveAccess) buildBackgroundBackupInfo(), + buildBackupButton(), + ] + : [ + buildFolderSelectionTile(), + if (!didGetBackupInfo.value) buildLoadingIndicator(), + ], + ), + ), + ], ), ); } From fe554c3a5bb0139d874ccd34cc947c7628543e5b Mon Sep 17 00:00:00 2001 From: Alex Sherwin Date: Sun, 15 Dec 2024 16:09:52 -0500 Subject: [PATCH 554/599] fix(mobile): set custom headers on external url (#14707) (#14708) --- mobile/lib/services/auth.service.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 0393470098..08741a15db 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -57,13 +57,18 @@ class AuthService { Future validateAuxilaryServerUrl(String url) async { final httpclient = HttpClient(); - final accessToken = _authRepository.getAccessToken(); bool isValid = false; try { final uri = Uri.parse('$url/users/me'); final request = await httpclient.getUrl(uri); - request.headers.add('x-immich-user-token', accessToken); + + // add auth token + any configured custom headers + final customHeaders = ApiService.getRequestHeaders(); + customHeaders.forEach((key, value) { + request.headers.add(key, value); + }); + final response = await request.close(); if (response.statusCode == 200) { isValid = true; From 6b0f9ec46cb5340932add131949c5952395aec7d Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Dec 2024 08:42:40 -0600 Subject: [PATCH 555/599] chore(mobile): post release tasks (#14656) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 49ac6c4cff..613a8fdf10 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -403,7 +403,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -546,7 +546,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -575,7 +575,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 28d21e266e..2a74f88485 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.122.2 + 1.122.3 CFBundleSignature ???? CFBundleVersion - 184 + 185 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 8945a5d862254bae244a52fa0edf04be5daf596c Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:45:01 +0100 Subject: [PATCH 556/599] fix: reduce the number of API requests when changing route (#14666) * fix: reduce the number of API requests when changing route * fix: reset `userInteraction` after sign out --- .../components/forms/create-user-form.svelte | 6 +++-- .../components/forms/edit-user-form.svelte | 5 ++-- .../navigation-bar/navigation-bar.svelte | 9 ++++--- .../side-bar/recent-albums.svelte | 6 +++++ .../side-bar/server-status.svelte | 9 ++++++- .../side-bar/storage-space.svelte | 13 ++++++---- web/src/lib/stores/server-info.store.ts | 4 --- web/src/lib/stores/user.svelte.ts | 26 +++++++++++++++++++ web/src/lib/utils/auth.ts | 5 ++-- 9 files changed, 63 insertions(+), 20 deletions(-) delete mode 100644 web/src/lib/stores/server-info.store.ts create mode 100644 web/src/lib/stores/user.svelte.ts diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index b1599a24b2..7aa1c76ed3 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -1,7 +1,7 @@ -{#if shouldShowHelpPanel && aboutInfo} - (shouldShowHelpPanel = false)} info={aboutInfo} /> +{#if shouldShowHelpPanel && info} + (shouldShowHelpPanel = false)} {info} /> {/if}
    diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte index d90d7dec01..b11935d643 100644 --- a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte @@ -4,13 +4,19 @@ import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error'; import { t } from 'svelte-i18n'; + import { userInteraction } from '$lib/stores/user.svelte'; let albums: AlbumResponseDto[] = $state([]); onMount(async () => { + if (userInteraction.recentAlbums) { + albums = userInteraction.recentAlbums; + return; + } try { const allAlbums = await getAllAlbums({}); albums = allAlbums.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)).slice(0, 3); + userInteraction.recentAlbums = albums; } catch (error) { handleError(error, $t('failed_to_load_assets')); } diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte index 2a0e6a0821..e1d7340c46 100644 --- a/web/src/lib/components/shared-components/side-bar/server-status.svelte +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -12,17 +12,24 @@ } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiAlert } from '@mdi/js'; + import { userInteraction } from '$lib/stores/user.svelte'; const { serverVersion, connected } = websocketStore; let isOpen = $state(false); - let info: ServerAboutResponseDto | undefined = $state(); let versions: ServerVersionHistoryResponseDto[] = $state([]); onMount(async () => { + if (userInteraction.aboutInfo && userInteraction.versions && $serverVersion) { + info = userInteraction.aboutInfo; + versions = userInteraction.versions; + return; + } await requestServerInfo(); [info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]); + userInteraction.aboutInfo = info; + userInteraction.versions = versions; }); let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich'); let version = $derived( diff --git a/web/src/lib/components/shared-components/side-bar/storage-space.svelte b/web/src/lib/components/shared-components/side-bar/storage-space.svelte index c0de9378ac..9472397565 100644 --- a/web/src/lib/components/shared-components/side-bar/storage-space.svelte +++ b/web/src/lib/components/shared-components/side-bar/storage-space.svelte @@ -1,18 +1,18 @@ @@ -54,7 +57,7 @@
    - - +
    diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 511792e536..1c1eee39ec 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -3,18 +3,20 @@ import FaceThumbnail from './face-thumbnail.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import { t } from 'svelte-i18n'; + import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { mdiSwapVertical } from '@mdi/js'; interface Props { screenHeight: number; people: PersonResponseDto[]; peopleToNotShow: PersonResponseDto[]; onSelect: (person: PersonResponseDto) => void; + handleSearch?: (sortFaces: boolean) => void; } - let { screenHeight, people, peopleToNotShow, onSelect }: Props = $props(); - + let { screenHeight, people, peopleToNotShow, onSelect, handleSearch }: Props = $props(); let searchedPeopleLocal: PersonResponseDto[] = $state([]); - + let sortBySimilarirty = $state(false); let name = $state(''); const showPeople = $derived( @@ -24,12 +26,26 @@ ); -
    - +
    +
    + +
    + + {#if handleSearch} + { + sortBySimilarirty = !sortBySimilarirty; + handleSearch(sortBySimilarirty); + }} + color="neutral" + title={$t('sort_people_by_similarity')} + > + {/if}
    From 6080e6e827250b069039538cb5d08ee7492b71c1 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 21 Dec 2024 13:26:01 -0600 Subject: [PATCH 585/599] fix(web): infinite loop browser navigation crash admin settings page (#14850) * fix(web): infinite loop browser navigation crash admin settings page * pr feedback --- .../settings/setting-accordion-state.svelte | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte index 4d97ee5cc6..6b3ae81685 100644 --- a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte @@ -9,9 +9,9 @@ import { writable, type Writable } from 'svelte/store'; import { createContext } from '$lib/utils/context'; import { page } from '$app/state'; - import { handlePromiseError } from '$lib/utils'; import { goto } from '$app/navigation'; import type { Snippet } from 'svelte'; + import { handlePromiseError } from '$lib/utils'; const getParamValues = (param: string) => { return new Set((page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== '')); @@ -26,17 +26,16 @@ let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props(); setAccordionState(state); - $effect(() => { - if (queryParam && $state) { - const searchParams = new URLSearchParams(page.url.searchParams); - if ($state.size > 0) { - searchParams.set(queryParam, [...$state].join(' ')); - } else { - searchParams.delete(queryParam); - } + const searchParams = new URLSearchParams(page.url.searchParams); - handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true })); + $effect(() => { + if ($state.size > 0) { + searchParams.set(queryParam, [...$state].join(' ')); + } else { + searchParams.delete(queryParam); } + + handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true })); }); From 4bc2aa54519f1b98f69f6ad9bcb588ce385b2215 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Sun, 22 Dec 2024 03:50:07 +0100 Subject: [PATCH 586/599] feat(server): Handle sidecars in external libraries (#14800) * handle sidecars in external libraries * don't add separate source --- e2e/src/api/specs/library.e2e-spec.ts | 358 ++++++++++++++++++-- e2e/test-assets | 2 +- server/src/services/library.service.spec.ts | 55 +-- server/src/services/library.service.ts | 14 +- server/src/services/metadata.service.ts | 9 +- 5 files changed, 355 insertions(+), 83 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 3f910fa1e3..dde2cf79eb 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,5 +1,5 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; -import { cpSync, existsSync } from 'node:fs'; +import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -406,65 +406,93 @@ describe('/libraries', () => { it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/reimport`], }); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001); const { status } = await request(app) .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ refreshModifiedFiles: true }); + .send(); expect(status).toBe(204); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, - model: 'NIKON D750', }); - expect(assets.count).toBe(1); + + expect(assets.count).toEqual(1); + + const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(asset).toEqual( + expect.objectContaining({ + originalFileName: 'asset.jpg', + exifInfo: expect.objectContaining({ + model: 'NIKON D750', + }), + }), + ); + + utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/reimport`], }); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); + await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); const { status } = await request(app) .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ refreshModifiedFiles: true }); + .send(); expect(status).toBe(204); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, - model: 'NIKON D750', }); - expect(assets.count).toBe(0); + + expect(assets.count).toEqual(1); + + const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + + expect(asset).toEqual( + expect.objectContaining({ + originalFileName: 'asset.jpg', + exifInfo: expect.not.objectContaining({ + model: 'NIKON D750', + }), + }), + ); + + utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); it('should set an asset offline if its file is missing', async () => { @@ -601,6 +629,298 @@ describe('/libraries', () => { expect(assets).toEqual(assetsBefore); }); + + describe('xmp metadata', async () => { + it('should import metadata from file.xmp', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should import metadata from file.ext.xmp', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2010-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2000-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2010-09-27T12:35:33.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file.ext.xmp to file metadata', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2010-07-20T17:27:12.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + + it('should switch from using file.xmp to file metadata', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/xmp`], + }); + + cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); + cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); + await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + + const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'glarus.nef', + fileCreatedAt: '2010-07-20T17:27:12.000Z', + }), + ]); + + rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); + }); + }); }); describe('POST /libraries/:id/validate', () => { diff --git a/e2e/test-assets b/e2e/test-assets index 99544a2004..9e3b964b08 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 99544a200412d553103cc7b8f1a28f339c7cffd9 +Subproject commit 9e3b964b080dca6f035b29b86e66454ae8aeda78 diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 43d6662d65..9b944045ab 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -414,7 +414,6 @@ describe(LibraryService.name, () => { localDateTime: expect.any(Date), type: AssetType.IMAGE, originalFileName: 'photo.jpg', - sidecarPath: null, isExternal: true, }, ], @@ -423,57 +422,9 @@ describe(LibraryService.name, () => { expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.METADATA_EXTRACTION, + name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.image.id, - source: 'upload', - }, - }, - ], - ]); - }); - - it('should import a new asset with sidecar', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - storageMock.checkFileExists.mockResolvedValue(true); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create.mock.calls).toEqual([ - [ - { - ownerId: mockUser.id, - libraryId: libraryStub.externalLibrary1.id, - checksum: expect.any(Buffer), - originalPath: '/data/user1/photo.jpg', - deviceAssetId: expect.any(String), - deviceId: 'Library Import', - fileCreatedAt: expect.any(Date), - fileModifiedAt: expect.any(Date), - localDateTime: expect.any(Date), - type: AssetType.IMAGE, - originalFileName: 'photo.jpg', - sidecarPath: '/data/user1/photo.jpg.xmp', - isExternal: true, - }, - ], - ]); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', }, }, ], @@ -507,7 +458,6 @@ describe(LibraryService.name, () => { localDateTime: expect.any(Date), type: AssetType.VIDEO, originalFileName: 'video.mp4', - sidecarPath: null, isExternal: true, }, ], @@ -516,10 +466,9 @@ describe(LibraryService.name, () => { expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.METADATA_EXTRACTION, + name: JobName.SIDECAR_DISCOVERY, data: { id: assetStub.image.id, - source: 'upload', }, }, ], diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index c0d24fea9e..0deddc8941 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -396,12 +396,6 @@ export class LibraryService extends BaseService { const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - // TODO: doesn't xmp replace the file extension? Will need investigation - let sidecarPath: string | null = null; - if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { - sidecarPath = `${assetPath}.xmp`; - } - const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; const mtime = stat.mtime; @@ -418,8 +412,6 @@ export class LibraryService extends BaseService { localDateTime: mtime, type: assetType, originalFileName: parse(assetPath).base, - - sidecarPath, isExternal: true, }); @@ -431,7 +423,11 @@ export class LibraryService extends BaseService { async queuePostSyncJobs(asset: AssetEntity) { this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + // We queue a sidecar discovery which, in turn, queues metadata extraction + await this.jobRepository.queue({ + name: JobName.SIDECAR_DISCOVERY, + data: { id: asset.id }, + }); } async queueScan(id: string) { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 79a7d519d6..e0566c84b7 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -698,7 +698,7 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - if (!isSync && (!asset.isVisible || asset.sidecarPath)) { + if (!isSync && (!asset.isVisible || asset.sidecarPath) && !asset.isExternal) { return JobStatus.FAILED; } @@ -720,6 +720,13 @@ export class MetadataService extends BaseService { sidecarPath = sidecarPathWithoutExt; } + if (asset.isExternal) { + if (sidecarPath !== asset.sidecarPath) { + await this.assetRepository.update({ id: asset.id, sidecarPath }); + } + return JobStatus.SUCCESS; + } + if (sidecarPath) { await this.assetRepository.update({ id: asset.id, sidecarPath }); return JobStatus.SUCCESS; From c3be74c450ba7d57ccfc9f6c0463478e579682a0 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Sun, 22 Dec 2024 23:22:16 +0100 Subject: [PATCH 587/599] fix(server): support import paths with special chars (#14856) --- e2e/src/api/specs/library.e2e-spec.ts | 61 +++++++++++++++++++ server/src/repositories/storage.repository.ts | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index dde2cf79eb..23cdf092cf 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -403,6 +403,67 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); }); + const annoyingChars = [ + "'", + '"', + '`', + '*', + '{', + '}', + ',', + '(', + ')', + '[', + ']', + '?', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '=', + '+', + '~', + '|', + '<', + '>', + ';', + ':', + '/', // We never got backslashes to work + ]; + + it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`], + }); + + utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); + utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); + + expect(assets.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }), + expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }), + ]), + ); + + utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); + }); + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index e4c0c68451..a8d3db15d8 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -214,7 +214,7 @@ export class StorageRepository implements IStorageRepository { } private asGlob(pathToCrawl: string): string { - const escapedPath = escapePath(pathToCrawl); + const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]'); const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; return `${escapedPath}/**/${extensions}`; } From b88f98bf66a4cec9f2d0c5ee77b1bcf6dcf92344 Mon Sep 17 00:00:00 2001 From: Ben <35833890+IMBeniamin@users.noreply.github.com> Date: Mon, 23 Dec 2024 19:26:53 +0100 Subject: [PATCH 588/599] feat(web): Add "set as featured" option for an asset (#14879) --- i18n/en.json | 1 + .../actions/set-person-featured-action.svelte | 29 +++++++++++++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 7 +++++ .../asset-viewer/asset-viewer.svelte | 4 +++ .../components/photos-page/asset-grid.svelte | 5 +++- .../[[assetId=id]]/+page.svelte | 1 + 6 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/asset-viewer/actions/set-person-featured-action.svelte diff --git a/i18n/en.json b/i18n/en.json index e1538db1e4..b5f8f3ca9a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1142,6 +1142,7 @@ "set": "Set", "set_as_album_cover": "Set as album cover", "set_as_profile_picture": "Set as profile picture", + "set_as_featured_photo": "Set as featured photo", "set_date_of_birth": "Set date of birth", "set_profile_picture": "Set profile picture", "set_slideshow_to_fullscreen": "Set Slideshow to fullscreen", diff --git a/web/src/lib/components/asset-viewer/actions/set-person-featured-action.svelte b/web/src/lib/components/asset-viewer/actions/set-person-featured-action.svelte new file mode 100644 index 0000000000..70e1c4f1ba --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/set-person-featured-action.svelte @@ -0,0 +1,29 @@ + + + diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 65ca01b58a..442302198b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -9,6 +9,7 @@ import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; + import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; @@ -27,6 +28,7 @@ AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto, + type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; import { @@ -50,6 +52,7 @@ interface Props { asset: AssetResponseDto; album?: AlbumResponseDto | null; + person?: PersonResponseDto | null; stack?: StackResponseDto | null; showDetailButton: boolean; showSlideshow?: boolean; @@ -67,6 +70,7 @@ let { asset, album = null, + person = null, stack = null, showDetailButton, showSlideshow = false, @@ -169,6 +173,9 @@ {#if album} {/if} + {#if person} + + {/if} {#if asset.type === AssetTypeEnum.Image} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 69acc5bb0a..7a2f97bb65 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -30,6 +30,7 @@ type ActivityResponseDto, type AlbumResponseDto, type AssetResponseDto, + type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; import { onDestroy, onMount, untrack } from 'svelte'; @@ -56,6 +57,7 @@ withStacked?: boolean; isShared?: boolean; album?: AlbumResponseDto | null; + person?: PersonResponseDto | null; onAction?: OnAction | undefined; reactions?: ActivityResponseDto[]; onClose: (dto: { asset: AssetResponseDto }) => void; @@ -72,6 +74,7 @@ withStacked = false, isShared = false, album = null, + person = null, onAction = undefined, reactions = $bindable([]), onClose, @@ -429,6 +432,7 @@ void; onEscape?: () => void; @@ -70,6 +71,7 @@ showArchiveIcon = false, isShared = false, album = null, + person = null, isShowDeleteConfirmation = $bindable(false), onSelect = () => {}, onEscape = () => {}, @@ -914,6 +916,7 @@ preloadAssets={$preloadAssets} {isShared} {album} + {person} onAction={handleAction} onPrevious={handlePrevious} onNext={handleNext} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6788c678ed..e1e50cfb2e 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -454,6 +454,7 @@ {#key person.id} Date: Mon, 23 Dec 2024 21:03:34 +0000 Subject: [PATCH 589/599] fix(deps): update dependency @nestjs/swagger to v8 (#13881) * fix(deps): update dependency @nestjs/swagger to v8 * chore: generate open api --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- mobile/openapi/README.md | 8 +- .../openapi/lib/model/album_user_add_dto.dart | 18 +- .../openapi/lib/model/create_library_dto.dart | 20 +- .../openapi/lib/model/update_library_dto.dart | 20 +- .../lib/model/validate_library_dto.dart | 20 +- open-api/immich-openapi-specs.json | 289 +++++++++++++++--- server/package-lock.json | 36 ++- server/package.json | 2 +- 8 files changed, 301 insertions(+), 112 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b336b1bfb6..a28035c01a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -93,17 +93,17 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | -*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | -*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | +*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Checks if assets exist by checksums +*AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Checks if multiple assets exist on the server and returns all existing - used by background backup *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | -*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | +*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Get all asset of a device that are in the database, ID only. *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | -*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | +*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace the asset with new file, without changing its id *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index 3f72d5c893..e1f24377d7 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -13,17 +13,11 @@ part of openapi.api; class AlbumUserAddDto { /// Returns a new [AlbumUserAddDto] instance. AlbumUserAddDto({ - this.role, + this.role = AlbumUserRole.editor, required this.userId, }); - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - AlbumUserRole? role; + AlbumUserRole role; String userId; @@ -35,7 +29,7 @@ class AlbumUserAddDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (role == null ? 0 : role!.hashCode) + + (role.hashCode) + (userId.hashCode); @override @@ -43,11 +37,7 @@ class AlbumUserAddDto { Map toJson() { final json = {}; - if (this.role != null) { json[r'role'] = this.role; - } else { - // json[r'role'] = null; - } json[r'userId'] = this.userId; return json; } @@ -61,7 +51,7 @@ class AlbumUserAddDto { final json = value.cast(); return AlbumUserAddDto( - role: AlbumUserRole.fromJson(json[r'role']), + role: AlbumUserRole.fromJson(json[r'role']) ?? AlbumUserRole.editor, userId: mapValueOfType(json, r'userId')!, ); } diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index bffa5f4279..2b8085be6f 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -13,15 +13,15 @@ part of openapi.api; class CreateLibraryDto { /// Returns a new [CreateLibraryDto] instance. CreateLibraryDto({ - this.exclusionPatterns = const [], - this.importPaths = const [], + this.exclusionPatterns = const {}, + this.importPaths = const {}, this.name, required this.ownerId, }); - List exclusionPatterns; + Set exclusionPatterns; - List importPaths; + Set importPaths; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -53,8 +53,8 @@ class CreateLibraryDto { Map toJson() { final json = {}; - json[r'exclusionPatterns'] = this.exclusionPatterns; - json[r'importPaths'] = this.importPaths; + json[r'exclusionPatterns'] = this.exclusionPatterns.toList(growable: false); + json[r'importPaths'] = this.importPaths.toList(growable: false); if (this.name != null) { json[r'name'] = this.name; } else { @@ -74,11 +74,11 @@ class CreateLibraryDto { return CreateLibraryDto( exclusionPatterns: json[r'exclusionPatterns'] is Iterable - ? (json[r'exclusionPatterns'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'exclusionPatterns'] as Iterable).cast().toSet() + : const {}, importPaths: json[r'importPaths'] is Iterable - ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'importPaths'] as Iterable).cast().toSet() + : const {}, name: mapValueOfType(json, r'name'), ownerId: mapValueOfType(json, r'ownerId')!, ); diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index b85df40172..6a4f36906f 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -13,14 +13,14 @@ part of openapi.api; class UpdateLibraryDto { /// Returns a new [UpdateLibraryDto] instance. UpdateLibraryDto({ - this.exclusionPatterns = const [], - this.importPaths = const [], + this.exclusionPatterns = const {}, + this.importPaths = const {}, this.name, }); - List exclusionPatterns; + Set exclusionPatterns; - List importPaths; + Set importPaths; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -48,8 +48,8 @@ class UpdateLibraryDto { Map toJson() { final json = {}; - json[r'exclusionPatterns'] = this.exclusionPatterns; - json[r'importPaths'] = this.importPaths; + json[r'exclusionPatterns'] = this.exclusionPatterns.toList(growable: false); + json[r'importPaths'] = this.importPaths.toList(growable: false); if (this.name != null) { json[r'name'] = this.name; } else { @@ -68,11 +68,11 @@ class UpdateLibraryDto { return UpdateLibraryDto( exclusionPatterns: json[r'exclusionPatterns'] is Iterable - ? (json[r'exclusionPatterns'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'exclusionPatterns'] as Iterable).cast().toSet() + : const {}, importPaths: json[r'importPaths'] is Iterable - ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'importPaths'] as Iterable).cast().toSet() + : const {}, name: mapValueOfType(json, r'name'), ); } diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 08199e3aa6..79ddb9a540 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -13,13 +13,13 @@ part of openapi.api; class ValidateLibraryDto { /// Returns a new [ValidateLibraryDto] instance. ValidateLibraryDto({ - this.exclusionPatterns = const [], - this.importPaths = const [], + this.exclusionPatterns = const {}, + this.importPaths = const {}, }); - List exclusionPatterns; + Set exclusionPatterns; - List importPaths; + Set importPaths; @override bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryDto && @@ -37,8 +37,8 @@ class ValidateLibraryDto { Map toJson() { final json = {}; - json[r'exclusionPatterns'] = this.exclusionPatterns; - json[r'importPaths'] = this.importPaths; + json[r'exclusionPatterns'] = this.exclusionPatterns.toList(growable: false); + json[r'importPaths'] = this.importPaths.toList(growable: false); return json; } @@ -52,11 +52,11 @@ class ValidateLibraryDto { return ValidateLibraryDto( exclusionPatterns: json[r'exclusionPatterns'] is Iterable - ? (json[r'exclusionPatterns'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'exclusionPatterns'] as Iterable).cast().toSet() + : const {}, importPaths: json[r'importPaths'] is Iterable - ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) - : const [], + ? (json[r'importPaths'] as Iterable).cast().toSet() + : const {}, ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7c8aba3b5e..2686d4f96d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1424,7 +1424,6 @@ }, "/assets/bulk-upload-check": { "post": { - "description": "Checks if assets exist by checksums", "operationId": "checkBulkUpload", "parameters": [], "requestBody": { @@ -1460,6 +1459,7 @@ "api_key": [] } ], + "summary": "Checks if assets exist by checksums", "tags": [ "Assets" ] @@ -1467,7 +1467,6 @@ }, "/assets/device/{deviceId}": { "get": { - "description": "Get all asset of a device that are in the database, ID only.", "operationId": "getAllUserAssetsByDeviceId", "parameters": [ { @@ -1505,6 +1504,7 @@ "api_key": [] } ], + "summary": "Get all asset of a device that are in the database, ID only.", "tags": [ "Assets" ] @@ -1512,7 +1512,6 @@ }, "/assets/exist": { "post": { - "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", "operationId": "checkExistingAssets", "parameters": [], "requestBody": { @@ -1548,6 +1547,7 @@ "api_key": [] } ], + "summary": "Checks if multiple assets exist on the server and returns all existing - used by background backup", "tags": [ "Assets" ] @@ -1903,7 +1903,6 @@ ] }, "put": { - "description": "Replace the asset with new file, without changing its id", "operationId": "replaceAsset", "parameters": [ { @@ -1957,6 +1956,7 @@ "api_key": [] } ], + "summary": "Replace the asset with new file, without changing its id", "tags": [ "Assets" ], @@ -7492,6 +7492,7 @@ "items": { "$ref": "#/components/schemas/Permission" }, + "minItems": 1, "type": "array" } }, @@ -7572,7 +7573,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/ReactionType" + "allOf": [ + { + "$ref": "#/components/schemas/ReactionType" + } + ] } }, "required": [ @@ -7599,7 +7604,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/ReactionType" + "allOf": [ + { + "$ref": "#/components/schemas/ReactionType" + } + ] }, "user": { "$ref": "#/components/schemas/UserResponseDto" @@ -7631,6 +7640,7 @@ "items": { "$ref": "#/components/schemas/AlbumUserAddDto" }, + "minItems": 1, "type": "array" } }, @@ -7699,7 +7709,11 @@ "type": "string" }, "order": { - "$ref": "#/components/schemas/AssetOrder" + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] }, "owner": { "$ref": "#/components/schemas/UserResponseDto" @@ -7759,7 +7773,12 @@ "AlbumUserAddDto": { "properties": { "role": { - "$ref": "#/components/schemas/AlbumUserRole" + "allOf": [ + { + "$ref": "#/components/schemas/AlbumUserRole" + } + ], + "default": "editor" }, "userId": { "format": "uuid", @@ -7774,7 +7793,11 @@ "AlbumUserCreateDto": { "properties": { "role": { - "$ref": "#/components/schemas/AlbumUserRole" + "allOf": [ + { + "$ref": "#/components/schemas/AlbumUserRole" + } + ] }, "userId": { "format": "uuid", @@ -7790,7 +7813,11 @@ "AlbumUserResponseDto": { "properties": { "role": { - "$ref": "#/components/schemas/AlbumUserRole" + "allOf": [ + { + "$ref": "#/components/schemas/AlbumUserRole" + } + ] }, "user": { "$ref": "#/components/schemas/UserResponseDto" @@ -8087,7 +8114,11 @@ "nullable": true }, "sourceType": { - "$ref": "#/components/schemas/SourceType" + "allOf": [ + { + "$ref": "#/components/schemas/SourceType" + } + ] } }, "required": [ @@ -8158,7 +8189,11 @@ "type": "integer" }, "sourceType": { - "$ref": "#/components/schemas/SourceType" + "allOf": [ + { + "$ref": "#/components/schemas/SourceType" + } + ] } }, "required": [ @@ -8254,7 +8289,11 @@ "type": "array" }, "name": { - "$ref": "#/components/schemas/AssetJobName" + "allOf": [ + { + "$ref": "#/components/schemas/AssetJobName" + } + ] } }, "required": [ @@ -8352,7 +8391,11 @@ "type": "string" }, "status": { - "$ref": "#/components/schemas/AssetMediaStatus" + "allOf": [ + { + "$ref": "#/components/schemas/AssetMediaStatus" + } + ] } }, "required": [ @@ -8490,7 +8533,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] }, "unassignedFaces": { "items": { @@ -8603,7 +8650,11 @@ "AvatarResponse": { "properties": { "color": { - "$ref": "#/components/schemas/UserAvatarColor" + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ] } }, "required": [ @@ -8614,7 +8665,11 @@ "AvatarUpdate": { "properties": { "color": { - "$ref": "#/components/schemas/UserAvatarColor" + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ] } }, "type": "object" @@ -8705,6 +8760,7 @@ "items": { "type": "string" }, + "minItems": 1, "type": "array" }, "deviceId": { @@ -8771,13 +8827,17 @@ "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true }, "importPaths": { "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true }, "name": { "type": "string" @@ -9246,10 +9306,18 @@ "type": "string" }, "entityType": { - "$ref": "#/components/schemas/PathEntityType" + "allOf": [ + { + "$ref": "#/components/schemas/PathEntityType" + } + ] }, "pathType": { - "$ref": "#/components/schemas/PathType" + "allOf": [ + { + "$ref": "#/components/schemas/PathType" + } + ] }, "pathValue": { "type": "string" @@ -9311,7 +9379,11 @@ "JobCommandDto": { "properties": { "command": { - "$ref": "#/components/schemas/JobCommand" + "allOf": [ + { + "$ref": "#/components/schemas/JobCommand" + } + ] }, "force": { "type": "boolean" @@ -9356,7 +9428,11 @@ "JobCreateDto": { "properties": { "name": { - "$ref": "#/components/schemas/ManualJobName" + "allOf": [ + { + "$ref": "#/components/schemas/ManualJobName" + } + ] } }, "required": [ @@ -9544,6 +9620,7 @@ "properties": { "email": { "example": "testuser@email.com", + "format": "email", "type": "string" }, "password": { @@ -9717,7 +9794,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/MemoryType" + "allOf": [ + { + "$ref": "#/components/schemas/MemoryType" + } + ] } }, "required": [ @@ -9782,7 +9863,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/MemoryType" + "allOf": [ + { + "$ref": "#/components/schemas/MemoryType" + } + ] }, "updatedAt": { "format": "date-time", @@ -9911,7 +9996,11 @@ "type": "string" }, "order": { - "$ref": "#/components/schemas/AssetOrder" + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] }, "originalFileName": { "type": "string" @@ -9962,7 +10051,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] }, "updatedAfter": { "format": "date-time", @@ -10046,7 +10139,11 @@ "PartnerResponseDto": { "properties": { "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ] }, "email": { "type": "string" @@ -10564,7 +10661,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] }, "updatedAfter": { "format": "date-time", @@ -11232,7 +11333,11 @@ "type": "boolean" }, "type": { - "$ref": "#/components/schemas/SharedLinkType" + "allOf": [ + { + "$ref": "#/components/schemas/SharedLinkType" + } + ] } }, "required": [ @@ -11317,7 +11422,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/SharedLinkType" + "allOf": [ + { + "$ref": "#/components/schemas/SharedLinkType" + } + ] }, "userId": { "type": "string" @@ -11350,6 +11459,7 @@ "properties": { "email": { "example": "testuser@email.com", + "format": "email", "type": "string" }, "name": { @@ -11466,7 +11576,11 @@ "type": "string" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] }, "updatedAfter": { "format": "date-time", @@ -11507,6 +11621,7 @@ "format": "uuid", "type": "string" }, + "minItems": 2, "type": "array" } }, @@ -11647,7 +11762,11 @@ "SystemConfigFFmpegDto": { "properties": { "accel": { - "$ref": "#/components/schemas/TranscodeHWAccel" + "allOf": [ + { + "$ref": "#/components/schemas/TranscodeHWAccel" + } + ] }, "accelDecode": { "type": "boolean" @@ -11676,7 +11795,11 @@ "type": "integer" }, "cqMode": { - "$ref": "#/components/schemas/CQMode" + "allOf": [ + { + "$ref": "#/components/schemas/CQMode" + } + ] }, "crf": { "maximum": 51, @@ -11702,13 +11825,21 @@ "type": "integer" }, "targetAudioCodec": { - "$ref": "#/components/schemas/AudioCodec" + "allOf": [ + { + "$ref": "#/components/schemas/AudioCodec" + } + ] }, "targetResolution": { "type": "string" }, "targetVideoCodec": { - "$ref": "#/components/schemas/VideoCodec" + "allOf": [ + { + "$ref": "#/components/schemas/VideoCodec" + } + ] }, "temporalAQ": { "type": "boolean" @@ -11718,10 +11849,18 @@ "type": "integer" }, "tonemap": { - "$ref": "#/components/schemas/ToneMapping" + "allOf": [ + { + "$ref": "#/components/schemas/ToneMapping" + } + ] }, "transcode": { - "$ref": "#/components/schemas/TranscodePolicy" + "allOf": [ + { + "$ref": "#/components/schemas/TranscodePolicy" + } + ] }, "twoPass": { "type": "boolean" @@ -11766,7 +11905,11 @@ "SystemConfigGeneratedImageDto": { "properties": { "format": { - "$ref": "#/components/schemas/ImageFormat" + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] }, "quality": { "maximum": 100, @@ -11788,7 +11931,11 @@ "SystemConfigImageDto": { "properties": { "colorspace": { - "$ref": "#/components/schemas/Colorspace" + "allOf": [ + { + "$ref": "#/components/schemas/Colorspace" + } + ] }, "extractEmbedded": { "type": "boolean" @@ -11906,7 +12053,11 @@ "type": "boolean" }, "level": { - "$ref": "#/components/schemas/LogLevel" + "allOf": [ + { + "$ref": "#/components/schemas/LogLevel" + } + ] } }, "required": [ @@ -11935,6 +12086,7 @@ "type": "string" }, "urls": { + "format": "uri", "items": { "format": "uri", "type": "string" @@ -11955,12 +12107,14 @@ "SystemConfigMapDto": { "properties": { "darkStyle": { + "format": "uri", "type": "string" }, "enabled": { "type": "boolean" }, "lightStyle": { + "format": "uri", "type": "string" } }, @@ -12035,6 +12189,7 @@ "type": "boolean" }, "mobileRedirectUri": { + "format": "uri", "type": "string" }, "profileSigningAlgorithm": { @@ -12097,6 +12252,7 @@ "SystemConfigServerDto": { "properties": { "externalDomain": { + "format": "uri", "type": "string" }, "loginPageMessage": { @@ -12353,6 +12509,7 @@ "TagCreateDto": { "properties": { "color": { + "pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$", "type": "string" }, "name": { @@ -12408,6 +12565,7 @@ "properties": { "color": { "nullable": true, + "pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$", "type": "string" } }, @@ -12570,7 +12728,11 @@ "type": "boolean" }, "order": { - "$ref": "#/components/schemas/AssetOrder" + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] } }, "type": "object" @@ -12578,7 +12740,11 @@ "UpdateAlbumUserDto": { "properties": { "role": { - "$ref": "#/components/schemas/AlbumUserRole" + "allOf": [ + { + "$ref": "#/components/schemas/AlbumUserRole" + } + ] } }, "required": [ @@ -12625,13 +12791,17 @@ "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true }, "importPaths": { "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true }, "name": { "type": "string" @@ -12697,6 +12867,7 @@ "UserAdminCreateDto": { "properties": { "email": { + "format": "email", "type": "string" }, "name": { @@ -12740,7 +12911,11 @@ "UserAdminResponseDto": { "properties": { "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ] }, "createdAt": { "format": "date-time", @@ -12795,7 +12970,11 @@ "type": "boolean" }, "status": { - "$ref": "#/components/schemas/UserStatus" + "allOf": [ + { + "$ref": "#/components/schemas/UserStatus" + } + ] }, "storageLabel": { "nullable": true, @@ -12830,6 +13009,7 @@ "UserAdminUpdateDto": { "properties": { "email": { + "format": "email", "type": "string" }, "name": { @@ -12967,7 +13147,11 @@ "UserResponseDto": { "properties": { "avatarColor": { - "$ref": "#/components/schemas/UserAvatarColor" + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ] }, "email": { "type": "string" @@ -13007,6 +13191,7 @@ "UserUpdateMeDto": { "properties": { "email": { + "format": "email", "type": "string" }, "name": { @@ -13035,13 +13220,17 @@ "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true }, "importPaths": { "items": { "type": "string" }, - "type": "array" + "maxItems": 128, + "type": "array", + "uniqueItems": true } }, "type": "object" diff --git a/server/package-lock.json b/server/package-lock.json index 347757a90b..3bdc0dc3da 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,7 +16,7 @@ "@nestjs/platform-express": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.2", "@nestjs/schedule": "^4.0.0", - "@nestjs/swagger": "^7.1.8", + "@nestjs/swagger": "^8.0.0", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", "@opentelemetry/auto-instrumentations-node": "^0.54.0", @@ -2099,9 +2099,9 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", - "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", + "integrity": "sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==", "license": "MIT", "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", @@ -2197,17 +2197,17 @@ "license": "MIT" }, "node_modules/@nestjs/swagger": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", - "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.0.tgz", + "integrity": "sha512-8hzH+r/31XshzXHC9vww4T0xjDAxMzvOaT1xAOvvY1LtXTWyNRCUP2iQsCYJOnnMrR+vydWjvRZiuB3hdvaHxA==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "^0.15.0", - "@nestjs/mapped-types": "2.0.5", + "@nestjs/mapped-types": "2.0.6", "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "3.3.0", - "swagger-ui-dist": "5.17.14" + "swagger-ui-dist": "5.18.2" }, "peerDependencies": { "@fastify/static": "^6.0.0 || ^7.0.0", @@ -4464,6 +4464,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -13764,10 +13771,13 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.17.14", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", - "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", - "license": "Apache-2.0" + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", + "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } }, "node_modules/symbol-observable": { "version": "4.0.0", diff --git a/server/package.json b/server/package.json index dcb166bb06..074dafa5d3 100644 --- a/server/package.json +++ b/server/package.json @@ -41,7 +41,7 @@ "@nestjs/platform-express": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.2", "@nestjs/schedule": "^4.0.0", - "@nestjs/swagger": "^7.1.8", + "@nestjs/swagger": "^8.0.0", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", "@opentelemetry/auto-instrumentations-node": "^0.54.0", From ef0070c3fd0379d319670ec0828138b0791ede0d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 20:04:55 -0500 Subject: [PATCH 590/599] fix(deps): update machine-learning (#14891) --- machine-learning/poetry.lock | 246 +++++++++++++++++------------------ 1 file changed, 123 insertions(+), 123 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index bfed9fab8d..eb8fe31dff 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1331,13 +1331,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.26.5" +version = "0.27.0" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.26.5-py3-none-any.whl", hash = "sha256:fb7386090bbe892072e64b85f7c4479fd2d65eea5f2543327c970d5169e83924"}, - {file = "huggingface_hub-0.26.5.tar.gz", hash = "sha256:1008bd18f60bfb65e8dbc0a97249beeeaa8c99d3c2fa649354df9fa5a13ed83b"}, + {file = "huggingface_hub-0.27.0-py3-none-any.whl", hash = "sha256:8f2e834517f1f1ddf1ecc716f91b120d7333011b7485f665a9a412eacb1a2a81"}, + {file = "huggingface_hub-0.27.0.tar.gz", hash = "sha256:902cce1a1be5739f5589e560198a65a8edcfd3b830b1666f36e4b961f0454fac"}, ] [package.dependencies] @@ -2492,18 +2492,18 @@ files = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" +pydantic-core = "2.27.2" typing-extensions = ">=4.12.2" [package.extras] @@ -2512,111 +2512,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [package.dependencies] @@ -2624,13 +2624,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.6.1" +version = "2.7.0" description = "Settings management using Pydantic" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87"}, - {file = "pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0"}, + {file = "pydantic_settings-2.7.0-py3-none-any.whl", hash = "sha256:e00c05d5fa6cbbb227c84bd7487c5c1065084119b750df7c8c1a554aed236eb5"}, + {file = "pydantic_settings-2.7.0.tar.gz", hash = "sha256:ac4bfd4a36831a48dbf8b2d9325425b549a0a6f18cea118436d728eb4f1c4d66"}, ] [package.dependencies] @@ -2706,20 +2706,20 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "0.25.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, - {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, + {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, + {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, ] [package.dependencies] pytest = ">=8.2,<9" [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -2787,13 +2787,13 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.19" +version = "0.0.20" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.19-py3-none-any.whl", hash = "sha256:f8d5b0b9c618575bf9df01c684ded1d94a338839bdd8223838afacfb4bb2082d"}, - {file = "python_multipart-0.0.19.tar.gz", hash = "sha256:905502ef39050557b7a6af411f454bc19526529ca46ae6831508438890ce12cc"}, + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, ] [[package]] @@ -3393,13 +3393,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.32.1" +version = "0.34.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"}, - {file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"}, + {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, + {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, ] [package.dependencies] From 23461e98fbc99ad05c4efd58cc6d583976cd409e Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 25 Dec 2024 14:07:52 +0100 Subject: [PATCH 591/599] fix: clarify PR label validation message (#14925) --- .github/workflows/pr-label-validation.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 754d409613..0abbc01afd 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -19,3 +19,4 @@ jobs: use_regex: true labels: "changelog:.*" add_comment: true + message: "Label error. Requires {{errorString}} {{count}} of: {{ provided }}. Found: {{ applied }}. A maintainer will add the required label." From 227eb4b0a68897cef110ea86c1628a77e971f9fe Mon Sep 17 00:00:00 2001 From: indam Date: Wed, 25 Dec 2024 21:09:47 +0800 Subject: [PATCH 592/599] docs: Update Chinese README (#14926) * Update Chinese README * retrigger checks --- readme_i18n/README_zh_CN.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 380dc25992..463e8aca9f 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -36,6 +36,7 @@ Português Brasileiro Svenska العربية + Tiếng Việt ภาษาไทย

    @@ -105,6 +106,8 @@ | 离线支持 | 是 | 否 | | 只读相册 | 是 | 是 | | 照片堆叠 | 是 | 是 | +| 标签 | 否 | 是 | +| 文件夹浏览 | 否 | 是 | ## 多语言 From 2be1cb7de27a98d2c878d632f554aadb5e21f45a Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 27 Dec 2024 16:20:07 +0100 Subject: [PATCH 593/599] fix(mobile): Fixed resolution format in Details (#14954) Fixed resolution format on mobile --- mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 0dd3305302..4af9846cf6 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -18,7 +18,7 @@ class FileInfo extends StatelessWidget { final height = asset.orientatedHeight ?? asset.height; final width = asset.orientatedWidth ?? asset.width; String resolution = - height != null && width != null ? "$height x $width " : ""; + height != null && width != null ? "$width x $height " : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; From 2255f3e966d129abd481b62712dc3b5bfb90a2b5 Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 27 Dec 2024 16:28:54 +0100 Subject: [PATCH 594/599] feat(mobile): Modified draggable area of detail modal (#14953) Modified draggable area of detail modal --- .../lib/pages/common/gallery_viewer.page.dart | 35 ++++++++++++------- .../asset_viewer/advanced_bottom_sheet.dart | 8 ++++- .../detail_panel/detail_panel.dart | 4 ++- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 5f77f28d8e..43ff43e573 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -127,18 +127,29 @@ class GalleryViewerPage extends HookConsumerWidget { context: context, useSafeArea: true, builder: (context) { - return FractionallySizedBox( - heightFactor: 0.75, - child: Padding( - padding: EdgeInsets.only( - bottom: context.viewInsets.bottom, - ), - child: ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset) - : DetailPanel(asset: asset), - ), + return DraggableScrollableSheet( + minChildSize: 0.5, + maxChildSize: 1, + initialChildSize: 0.75, + expand: false, + builder: (context, scrollController) { + return Padding( + padding: EdgeInsets.only( + bottom: context.viewInsets.bottom, + ), + child: ref.watch(appSettingsServiceProvider).getSetting( + AppSettingsEnum.advancedTroubleshooting, + ) + ? AdvancedBottomSheet( + assetDetail: asset, + scrollController: scrollController, + ) + : DetailPanel( + asset: asset, + scrollController: scrollController, + ), + ); + }, ); }, ); diff --git a/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart b/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart index 367519fead..1e6aba2bda 100644 --- a/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart +++ b/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart @@ -6,12 +6,18 @@ import 'package:immich_mobile/entities/asset.entity.dart'; class AdvancedBottomSheet extends HookConsumerWidget { final Asset assetDetail; + final ScrollController? scrollController; - const AdvancedBottomSheet({super.key, required this.assetDetail}); + const AdvancedBottomSheet({ + super.key, + required this.assetDetail, + this.scrollController, + }); @override Widget build(BuildContext context, WidgetRef ref) { return SingleChildScrollView( + controller: scrollController, child: Container( margin: const EdgeInsets.symmetric(horizontal: 8.0), child: LayoutBuilder( diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart index db9dafebcb..8ad2cdc687 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart @@ -9,12 +9,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; class DetailPanel extends HookConsumerWidget { final Asset asset; + final ScrollController? scrollController; - const DetailPanel({super.key, required this.asset}); + const DetailPanel({super.key, required this.asset, this.scrollController}); @override Widget build(BuildContext context, WidgetRef ref) { return ListView( + controller: scrollController, shrinkWrap: true, children: [ Padding( From 05cea0fc6966d9d5f3571c2ee662afd133649718 Mon Sep 17 00:00:00 2001 From: Sam Debruyn Date: Sat, 28 Dec 2024 02:45:23 +1100 Subject: [PATCH 595/599] chore(mobile): remove duplicate settingsservice (#14946) remove duplicate settingsservice --- mobile/lib/services/background.service.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 27be2c046d..c059f48f0e 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -372,7 +372,6 @@ class BackgroundService { HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); - AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); @@ -422,7 +421,7 @@ class BackgroundService { ); BackupService backupService = BackupService( apiService, - settingService, + settingsService, albumService, albumMediaRepository, fileMediaRepository, From 139090715e5273c06468cfdc61ae85496482ab09 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 27 Dec 2024 16:51:07 +0100 Subject: [PATCH 596/599] fix: trusted proxies (#14888) --- server/src/repositories/config.repository.spec.ts | 2 +- server/src/repositories/config.repository.ts | 2 +- server/src/workers/api.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 2ff5f53073..aa7fb87ac5 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -204,7 +204,7 @@ describe('getEnv', () => { it('should return default network options', () => { const { network } = getEnv(); expect(network).toEqual({ - trustedProxies: [], + trustedProxies: ['linklocal', 'uniquelocal'], }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index a8a1c9972b..cc05fd927c 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -177,7 +177,7 @@ const getEnv = (): EnvData => { licensePublicKey: isProd ? productionKeys : stagingKeys, network: { - trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? [], + trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'], }, otel: { diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 5196e7595c..efc705deaf 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -32,7 +32,7 @@ async function bootstrap() { logger.setContext('Bootstrap'); app.useLogger(logger); - app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal', ...network.trustedProxies]); + app.set('trust proxy', ['loopback', ...network.trustedProxies]); app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); From b91f39d1af16a10f32ddc00fd507e03cc89badf5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2024 09:51:42 -0600 Subject: [PATCH 597/599] chore(deps): update base-image to v20241224 (major) (#14905) chore(deps): update base-image to v20241224 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 3b2ac262d0..4c1aecb8fa 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20241217@sha256:7e69fa317cf90a0345927bbea13438dc39efc584bac13ff77ea5735c57cd008a AS dev +FROM ghcr.io/immich-app/base-server-dev:20241224@sha256:6832c632c2a8cba5e20053ab694c9a8080e621841c784ed5d4675ef9dd203588 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20241217@sha256:040c83a6d3e45755419837747fa70fa68cf92433d483c116a971b3400bb8415d +FROM ghcr.io/immich-app/base-server-prod:20241224@sha256:69da007c241a961d6927d3d03f1c83ef0ec5c70bf656bff3ced32546a777e6f6 WORKDIR /usr/src/app ENV NODE_ENV=production \ From 0250a7a23a93ecfaf09ed89d6fafac53ea58237e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 27 Dec 2024 11:16:07 -0500 Subject: [PATCH 598/599] fix(web): Fix for failing to load pictures (#14943) * attempt at fix for failing to load pictures * comments * remove unused files --------- Co-authored-by: Alex Tran --- web/src/lib/stores/assets.store.ts | 4 +++- .../[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 5412464766..215707543c 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -398,7 +398,9 @@ export class AssetStore { } async updateOptions(options: AssetStoreOptions) { - if (!this.initialized) { + // Make sure to re-initialize if the personId changes + const needsReinitializing = this.options.personId !== options.personId; + if (!this.initialized && !needsReinitializing) { this.setOptions(options); return; } diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index e1e50cfb2e..79760b192c 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -74,8 +74,13 @@ const assetStore = new AssetStore(assetStoreOptions); $effect(() => { + // Check to trigger rebuild the timeline when navigating between people from the info panel + const change = assetStoreOptions.personId !== data.person.id; assetStoreOptions.personId = data.person.id; handlePromiseError(assetStore.updateOptions(assetStoreOptions)); + if (change) { + assetStore.triggerUpdate(); + } }); const assetInteraction = new AssetInteraction(); From 34ce61d03a206f616325491281882afff4b617f8 Mon Sep 17 00:00:00 2001 From: mehring <34731361+MehringTing@users.noreply.github.com> Date: Sat, 28 Dec 2024 01:29:57 +0800 Subject: [PATCH 599/599] feat(web): create tag on the fly (#14726) --- .../components/forms/tag-asset-form.svelte | 28 ++++++++----------- .../shared-components/combobox.svelte | 26 +++++++++++++---- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index a95b67494e..3419e62a18 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -5,10 +5,8 @@ import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte'; import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import { onMount } from 'svelte'; - import { getAllTags, type TagResponseDto } from '@immich/sdk'; + import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; - import { AppRoute } from '$lib/constants'; - import FormatMessage from '$lib/components/i18n/format-message.svelte'; import { SvelteSet } from 'svelte/reactivity'; interface Props { @@ -22,6 +20,7 @@ let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); let selectedIds = $state(new SvelteSet()); let disabled = $derived(selectedIds.size === 0); + let allowCreate: boolean = $state(true); onMount(async () => { allTags = await getAllTags(); @@ -29,12 +28,18 @@ const handleSubmit = () => onTag([...selectedIds]); - const handleSelect = (option?: ComboBoxOption) => { + const handleSelect = async (option?: ComboBoxOption) => { if (!option) { return; } - selectedIds.add(option.value); + if (option.id) { + selectedIds.add(option.value); + } else { + const [newTag] = await upsertTags({ tagUpsertDto: { tags: [option.label] } }); + allTags.push(newTag); + selectedIds.add(newTag.id); + } }; const handleRemove = (tag: string) => { @@ -48,22 +53,13 @@ -
    -

    - - {#snippet children({ message })} - - {message} - - {/snippet} - -

    -
    ({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} /> diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 9dcb4d8f25..a6a1422eef 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -36,6 +36,14 @@ options?: ComboBoxOption[]; selectedOption?: ComboBoxOption | undefined; placeholder?: string; + /** + * whether creating new items is allowed. + */ + allowCreate?: boolean; + /** + * select first matching option on enter key. + */ + defaultFirstOption?: boolean; onSelect?: (option: ComboBoxOption | undefined) => void; } @@ -45,6 +53,8 @@ options = [], selectedOption = $bindable(), placeholder = '', + allowCreate = false, + defaultFirstOption = false, onSelect = () => {}, }: Props = $props(); @@ -141,7 +151,7 @@ const onInput: FormEventHandler = (event) => { openDropdown(); searchQuery = event.currentTarget.value; - selectedIndex = undefined; + selectedIndex = defaultFirstOption ? 0 : undefined; optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; @@ -221,9 +231,15 @@ searchQuery = selectedOption ? selectedOption.label : ''; }); - let filteredOptions = $derived( - options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), - ); + let filteredOptions = $derived.by(() => { + const _options = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + + if (allowCreate && searchQuery !== '' && _options.filter((option) => option.label === searchQuery).length === 0) { + _options.unshift({ label: searchQuery, value: searchQuery }); + } + + return _options; + }); let position = $derived(calculatePosition(bounds)); let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport)); @@ -352,7 +368,7 @@ id={`${listboxId}-${0}`} onclick={() => closeDropdown()} > - {$t('no_results')} + {allowCreate ? searchQuery : $t('no_results')} {/if} {#each filteredOptions as option, index (option.id || option.label)}