Compare commits

..

13 Commits

Author SHA1 Message Date
midzelis
19f276b543 feat: socket.io redis->postgres socket.io, add broadcastchannel option 2026-02-14 18:50:30 +00:00
Min Idzelis
2c9d69865c fix: e2e exit 135 (#26214) 2026-02-14 12:51:54 -05:00
bo0tzz
72cefcabaf chore: discourage LLM-generated PRs (#26211)
* chore: discourage LLM-generated PRs

* chore: add reading CONTRIBUTING.md to PR checklist

* chore: workflow to close LLM-generated PRs
2026-02-14 10:40:27 -06:00
Xantin
2fb9f84b56 refactor(i18n): Follow IETF standard (#26171)
* refactor(18n):  Follow IETF standard

Rename zh_SIMPLIFIED to zh_Hans

Makes it easier to merge #21337

* fix(web): zh_SIMPLIFIED -> zh_Hans
2026-02-13 18:47:41 +01:00
Weblate (bot)
434ded92f5 chore(web): update translations (#26167)
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/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translation: Immich/immich

Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Kuba <kubaant@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: Ulices <hasecilu@tuta.io>
Co-authored-by: Yu-Hsuan Liao <EMC521@outlook.com>
Co-authored-by: albanobattistella <albano_battistella@hotmail.com>
Co-authored-by: jcreusand <joan.creusandreu@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: Óscar Fernández Díaz <42654671+oscfdezdz@users.noreply.github.com>
2026-02-13 17:45:35 +00:00
agent-steven
bc7a1c838c fix(web): add checkerboard background for transparent images (#26091)
Co-authored-by: steven94kr <rlgns98kr@gmail.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-02-13 17:18:44 +00:00
Daniel Dietzler
7cb355279e chore: remove asset stubs (#26187) 2026-02-13 11:00:31 -05:00
Nykri
ecb09501a5 feat(cli): change progress bar to display file size (#23328)
* Change progress bar to display file size

* Solved lint errors
2026-02-13 10:22:00 -05:00
Michel Heusschen
34eb2e1410 fix(web): timeline multi select group state (#26180) 2026-02-13 08:34:15 -05:00
shenlong
2d6580acd8 feat(mobile): dynamic layout in new timeline (#23837)
* feat(mobile): dynamic layout in new timeline

* simplify _buildAssetRow

* auto dynamic mode on smaller column count

* auto layout on smaller tiles

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-13 09:15:42 +05:30
Yaros
9aa3fe82c1 fix(mobile): inconsistent query for people (#24437)
* fix(mobile): inconsistent query for people

* refactor: implement suggestions

* refactor: refactored query impl suggestions
2026-02-13 09:13:21 +05:30
Arne Schwarck
66733eb4c0 feat: add people deeplink (#25686)
* Change path prefix from '/memories/' to '/people/'

Updated the AndroidManifest.xml to change the path prefix from '/memories/' to '/people/'.
Memories is anyway wrong and was replaced by /memory
and now the people path completes the known deeplinks.

* Add regex for people deep link handling

Add regex for people deep link handling

* Add deep link handling for 'people' route

* fix: missing person route builder method

---------

Co-authored-by: bwees <brandonwees@gmail.com>
2026-02-13 09:13:04 +05:30
Thomas
e5156df4f1 fix(mobile): hide latest version warnings (#26036)
The latest version is already hidden in the server info widget if
disabled (https://github.com/immich-app/immich/pull/25691), however I
did not realise there are more places where this warning is shown. This
hides the warning everywhere, and cleans up the code a bit.
2026-02-13 08:15:25 +05:30
97 changed files with 2460 additions and 2752 deletions

View File

@@ -26,6 +26,7 @@ The `/api/something` endpoint is now `/api/something-else`
## Checklist:
- [ ] I have carefully read CONTRIBUTING.md
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR.

38
.github/workflows/close-llm-pr.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Close LLM-generated PRs
on:
pull_request:
types: [labeled]
permissions: {}
jobs:
comment_and_close:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'llm-generated' }}
permissions:
pull-requests: write
steps:
- name: Comment and close
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our CONTRIBUTING.md, we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

View File

@@ -17,7 +17,7 @@ If you are looking for something to work on, there are discussions and issues wi
## Use of generative AI
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
## Feature freezes

View File

@@ -180,18 +180,49 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
}
let multiBar: MultiBar | undefined;
let totalSize = 0;
const statsMap = new Map<string, Stats>();
// Calculate total size first
for (const filepath of files) {
const stats = await stat(filepath);
statsMap.set(filepath, stats);
totalSize += stats.size;
}
if (progress) {
multiBar = new MultiBar(
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
{
format: '{message} | {bar} | {percentage}% | ETA: {eta_formatted} | {value}/{total}',
formatValue: (v: number, options, type) => {
// Don't format percentage
if (type === 'percentage') {
return v.toString();
}
return byteSize(v).toString();
},
etaBuffer: 100, // Increase samples for ETA calculation
},
Presets.shades_classic,
);
// Ensure we restore cursor on interrupt
process.on('SIGINT', () => {
if (multiBar) {
multiBar.stop();
}
process.exit(0);
});
} else {
console.log(`Received ${files.length} files, hashing...`);
console.log(`Received ${files.length} files (${byteSize(totalSize)}), hashing...`);
}
const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' });
const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' });
const hashProgressBar = multiBar?.create(totalSize, 0, {
message: 'Hashing files ',
});
const checkProgressBar = multiBar?.create(totalSize, 0, {
message: 'Checking for duplicates',
});
const newFiles: string[] = [];
const duplicates: Asset[] = [];
@@ -211,7 +242,13 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
}
}
checkProgressBar?.increment(assets.length);
// Update progress based on total size of processed files
let processedSize = 0;
for (const asset of assets) {
const stats = statsMap.get(asset.id);
processedSize += stats?.size || 0;
}
checkProgressBar?.increment(processedSize);
},
{ concurrency, retry: 3 },
);
@@ -221,6 +258,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
async (filepath: string): Promise<AssetBulkUploadCheckItem[]> => {
const stats = statsMap.get(filepath);
if (!stats) {
throw new Error(`Stats not found for ${filepath}`);
}
const dto = { id: filepath, checksum: await sha1(filepath) };
results.push(dto);
@@ -231,7 +272,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
void checkBulkUploadQueue.push(batch);
}
hashProgressBar?.increment();
hashProgressBar?.increment(stats.size);
return results;
},
{ concurrency, retry: 3 },

View File

@@ -10,6 +10,7 @@ services:
immich-server:
container_name: immich-e2e-server
image: immich-server:latest
shm_size: 128mb
build:
context: ../
dockerfile: server/Dockerfile
@@ -53,6 +54,7 @@ services:
POSTGRES_DB: immich
ports:
- 5435:5432
shm_size: 128mb
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s

View File

@@ -311,7 +311,7 @@
"search_jobs": "Cercar treballs…",
"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)://",
"server_external_domain_settings_description": "Domini utilitzat per a enllaços externs",
"server_public_users": "Usuaris públics",
"server_public_users_description": "Tots els usuaris (nom i correu electrònic) apareixen a la llista a l'afegir un usuari als àlbums compartits. Si es desactiva, la llista només serà disponible pels usuaris administradors.",
"server_settings": "Configuració del servidor",
@@ -794,6 +794,11 @@
"color": "Color",
"color_theme": "Tema de color",
"command": "Ordre",
"command_palette_prompt": "Trobar ràpidament pàgines, accions o comandes",
"command_palette_to_close": "per a tancar",
"command_palette_to_navigate": "per a introduir",
"command_palette_to_select": "per a seleccionar",
"command_palette_to_show_all": "per a mostrar-ho tot",
"comment_deleted": "Comentari esborrat",
"comment_options": "Opcions de comentari",
"comments_and_likes": "Comentaris i agradaments",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "PERSONES",
"exif_bottom_sheet_person_add_person": "Afegir nom",
"exit_slideshow": "Surt de la presentació de diapositives",
"expand": "Ampliar-ho",
"expand_all": "Ampliar-ho tot",
"experimental_settings_new_asset_list_subtitle": "Treball en curs",
"experimental_settings_new_asset_list_title": "Habilita la graella de fotos experimental",
@@ -1532,7 +1538,7 @@
"mobile_app_download_onboarding_note": "Descarregar la App de mòbil fent servir les seguents opcions",
"model": "Model",
"month": "Mes",
"monthly_title_text_date_format": "MMMM y",
"monthly_title_text_date_format": "MMMM a",
"more": "Més",
"move": "Moure",
"move_down": "Moure cap avall",
@@ -1642,6 +1648,7 @@
"online": "En línia",
"only_favorites": "Només preferits",
"open": "Obrir",
"open_calendar": "Obrir el calendari",
"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",
@@ -2183,6 +2190,7 @@
"support": "Suport",
"support_and_feedback": "Suport i comentaris",
"support_third_party_description": "La vostra instal·lació immich la va empaquetar un tercer. Els problemes que experimenteu poden ser causats per aquest paquet així que, si us plau, plantegeu els poblemes amb ells en primer lloc mitjançant els enllaços següents.",
"supporter": "Contribuïdor",
"swap_merge_direction": "Canvia la direcció d'unió",
"sync": "Sincronitza",
"sync_albums": "Sincronitzar àlbums",

View File

@@ -311,7 +311,7 @@
"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)://",
"server_external_domain_settings_description": "Doména používaná pro externí odkazy",
"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",
@@ -794,6 +794,11 @@
"color": "Barva",
"color_theme": "Barevný motiv",
"command": "Příkaz",
"command_palette_prompt": "Rychlé vyhledávání stránek, akcí nebo příkazů",
"command_palette_to_close": "zavřít",
"command_palette_to_navigate": "vstoupit",
"command_palette_to_select": "vybrat",
"command_palette_to_show_all": "zobrazit vše",
"comment_deleted": "Komentář odstraněn",
"comment_options": "Možnosti komentáře",
"comments_and_likes": "Komentáře a lajky",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "LIDÉ",
"exif_bottom_sheet_person_add_person": "Přidat jméno",
"exit_slideshow": "Ukončit prezentaci",
"expand": "Rozbalit",
"expand_all": "Rozbalit vše",
"experimental_settings_new_asset_list_subtitle": "Zpracovávám",
"experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií",
@@ -1642,6 +1648,7 @@
"online": "Online",
"only_favorites": "Pouze oblíbené",
"open": "Otevřít",
"open_calendar": "Otevřít kalendář",
"open_in_map_view": "Otevřít v zobrazení mapy",
"open_in_openstreetmap": "Otevřít v OpenStreetMap",
"open_the_search_filters": "Otevřít vyhledávací filtry",
@@ -2183,6 +2190,7 @@
"support": "Podpora",
"support_and_feedback": "Podpora a zpětná vazba",
"support_third_party_description": "Vaše Immich instalace byla připravena třetí stranou. Problémy, které se u vás vyskytly, mohou být způsobeny tímto balíčkem, proto se na ně obraťte v první řadě pomocí níže uvedených odkazů.",
"supporter": "Podporovatel",
"swap_merge_direction": "Obrátit směr sloučení",
"sync": "Synchronizovat",
"sync_albums": "Synchronizovat alba",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Buscar trabajos…",
"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_external_domain_settings_description": "Dominio usado para enlaces externos",
"server_public_users": "Usuarios públicos",
"server_public_users_description": "Cuando se añade un usuario a los álbumes compartidos, todos los usuarios aparecen en una lista con su nombre y su correo electrónico. Si deshabilita esta opción, solo los administradores podrán ver la lista de usuarios.",
"server_settings": "Configuración del servidor",
@@ -794,6 +794,11 @@
"color": "Color",
"color_theme": "Color del tema",
"command": "Comando",
"command_palette_prompt": "Encuentra rápidamente páginas, acciones o comandos",
"command_palette_to_close": "para cerrar",
"command_palette_to_navigate": "para entrar",
"command_palette_to_select": "para seleccionar",
"command_palette_to_show_all": "para mostrar todo",
"comment_deleted": "Comentario borrado",
"comment_options": "Opciones de comentarios",
"comments_and_likes": "Comentarios y me gusta",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "PERSONAS",
"exif_bottom_sheet_person_add_person": "Añadir nombre",
"exit_slideshow": "Salir de la presentación",
"expand": "Expandir",
"expand_all": "Expandir todo",
"experimental_settings_new_asset_list_subtitle": "Trabajo en progreso",
"experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental",
@@ -1642,6 +1648,7 @@
"online": "En línea",
"only_favorites": "Solo favoritos",
"open": "Abierto",
"open_calendar": "Abrir calendario",
"open_in_map_view": "Abrir en la vista del mapa",
"open_in_openstreetmap": "Abrir en OpenStreetMap",
"open_the_search_filters": "Abre los filtros de búsqueda",
@@ -1764,7 +1771,7 @@
"profile_picture_set": "Conjunto de imágenes de perfil.",
"public_album": "Álbum público",
"public_share": "Compartir públicamente",
"purchase_account_info": "Seguidor",
"purchase_account_info": "Colaborador",
"purchase_activated_subtitle": "Gracias por apoyar a Immich y al software de código abierto",
"purchase_activated_time": "Activado el {date}",
"purchase_activated_title": "Su clave ha sido activada correctamente",
@@ -1777,7 +1784,7 @@
"purchase_button_select": "Seleccionar",
"purchase_failed_activation": "¡Error al activar! ¡Por favor, revisa tu correo electrónico para obtener la clave del producto correcta!",
"purchase_individual_description_1": "Para un usuario",
"purchase_individual_description_2": "Estado de soporte",
"purchase_individual_description_2": "Estatus de colaborador",
"purchase_individual_title": "Individual",
"purchase_input_suggestion": "¿Tiene una clave de producto? Introdúzcala a continuación",
"purchase_license_subtitle": "Compre Immich para apoyar el desarrollo continuo del servicio",
@@ -1793,7 +1800,7 @@
"purchase_remove_server_product_key": "Eliminar la clave de producto del servidor",
"purchase_remove_server_product_key_prompt": "¿Está seguro de que desea eliminar la clave de producto del servidor?",
"purchase_server_description_1": "Para todo el servidor",
"purchase_server_description_2": "Estado del soporte",
"purchase_server_description_2": "Estatus de colaborador",
"purchase_server_title": "Servidor",
"purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador",
"query_asset_id": "Consultar ID de recurso",
@@ -2183,6 +2190,7 @@
"support": "Soporte",
"support_and_feedback": "Soporte y comentarios",
"support_third_party_description": "Esta instalación de Immich fue empaquetada por un tercero. Los problemas actuales pueden ser ocasionados por ese paquete; por favor, discuta sus inconvenientes con el empaquetador antes de usar los enlaces de abajo.",
"supporter": "Colaborador",
"swap_merge_direction": "Alternar dirección de mezcla",
"sync": "Sincronizar",
"sync_albums": "Sincronizar álbumes",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Otsi töödet…",
"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_external_domain_settings_description": "Domeen väliste linkide jaoks",
"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",
@@ -794,6 +794,11 @@
"color": "Värv",
"color_theme": "Värviteema",
"command": "Käsk",
"command_palette_prompt": "Leia kiirelt lehti, tegevusi või käske",
"command_palette_to_close": "sulge",
"command_palette_to_navigate": "sisene",
"command_palette_to_select": "vali",
"command_palette_to_show_all": "näita kõiki",
"comment_deleted": "Kommentaar kustutatud",
"comment_options": "Kommentaari valikud",
"comments_and_likes": "Kommentaarid ja meeldimised",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "ISIKUD",
"exif_bottom_sheet_person_add_person": "Lisa nimi",
"exit_slideshow": "Sulge slaidiesitlus",
"expand": "Laienda",
"expand_all": "Näita kõik",
"experimental_settings_new_asset_list_subtitle": "Töös",
"experimental_settings_new_asset_list_title": "Luba eksperimentaalne fotoruudistik",
@@ -1642,6 +1648,7 @@
"online": "Ühendatud",
"only_favorites": "Ainult lemmikud",
"open": "Ava",
"open_calendar": "Ava kalender",
"open_in_map_view": "Ava kaardi vaates",
"open_in_openstreetmap": "Ava OpenStreetMap",
"open_the_search_filters": "Ava otsingufiltrid",
@@ -2183,6 +2190,7 @@
"support": "Tugi",
"support_and_feedback": "Tugi ja tagasiside",
"support_third_party_description": "Sinu Immich'i install on kolmanda osapoole pakendatud. Probleemid, mida täheldad, võivad olla põhjustatud selle pakendamise poolt, seega võta esmajärjekorras nendega ühendust, kasutades allolevaid linke.",
"supporter": "Toetaja",
"swap_merge_direction": "Muuda ühendamise suunda",
"sync": "Sünkrooni",
"sync_albums": "Sünkrooni albumid",

View File

@@ -311,7 +311,7 @@
"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)://",
"server_external_domain_settings_description": "Nom de domaine utilisé pour les liens externes",
"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",
@@ -794,6 +794,11 @@
"color": "Couleur",
"color_theme": "Thème de couleur",
"command": "Commande",
"command_palette_prompt": "Trouver rapidement des pages, actions ou commandes",
"command_palette_to_close": "pour fermer",
"command_palette_to_navigate": "pour entrer",
"command_palette_to_select": "pour sélectionner",
"command_palette_to_show_all": "pour tout afficher",
"comment_deleted": "Commentaire supprimé",
"comment_options": "Options des commentaires",
"comments_and_likes": "Commentaires et \"J'aime\"",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "PERSONNES",
"exif_bottom_sheet_person_add_person": "Ajouter un nom",
"exit_slideshow": "Quitter le diaporama",
"expand": "Développer",
"expand_all": "Tout développer",
"experimental_settings_new_asset_list_subtitle": "En cours de développement",
"experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale",
@@ -1642,6 +1648,7 @@
"online": "En ligne",
"only_favorites": "Uniquement les favoris",
"open": "Ouvrir",
"open_calendar": "Ouvrir le calendrier",
"open_in_map_view": "Montrer sur la carte",
"open_in_openstreetmap": "Ouvrir dans OpenStreetMap",
"open_the_search_filters": "Ouvrir les filtres de recherche",
@@ -2183,6 +2190,7 @@
"support": "Soutenir",
"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.",
"supporter": "Contributeur",
"swap_merge_direction": "Inverser la direction de fusion",
"sync": "Synchroniser",
"sync_albums": "Synchroniser dans des albums",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Cuardaigh poist…",
"send_welcome_email": "Seol ríomhphost fáilte",
"server_external_domain_settings": "Fearann seachtrach",
"server_external_domain_settings_description": "Fearann le haghaidh naisc chomhroinnte poiblí, lena n-áirítear http(s)://",
"server_external_domain_settings_description": "Fearann a úsáidtear le haghaidh naisc sheachtracha",
"server_public_users": "Úsáideoirí Poiblí",
"server_public_users_description": "Liostaítear gach úsáideoir (ainm agus ríomhphost) nuair a chuirtear úsáideoir le halbaim chomhroinnte. Nuair a bhíonn sé díchumasaithe, ní bheidh an liosta úsáideoirí ar fáil ach dúsáideoirí riarthóra.",
"server_settings": "Socruithe Freastalaí",
@@ -794,6 +794,11 @@
"color": "Dath",
"color_theme": "Téama datha",
"command": "Ordú",
"command_palette_prompt": "Aimsigh leathanaigh, gníomhartha nó orduithe go tapa",
"command_palette_to_close": "a dhúnadh",
"command_palette_to_navigate": "dul isteach",
"command_palette_to_select": "a roghnú",
"command_palette_to_show_all": "chun gach rud a thaispeáint",
"comment_deleted": "Trácht scriosta",
"comment_options": "Roghanna tráchta",
"comments_and_likes": "Tráchtanna & Is maith liom",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "DAOINE",
"exif_bottom_sheet_person_add_person": "Cuir ainm leis",
"exit_slideshow": "Scoir an Taispeántais Sleamhnán",
"expand": "Leathnaigh",
"expand_all": "Leathnaigh gach rud",
"experimental_settings_new_asset_list_subtitle": "Obair ar siúl",
"experimental_settings_new_asset_list_title": "Cumasaigh eangach grianghraf turgnamhach",
@@ -1642,6 +1648,7 @@
"online": "Ar líne",
"only_favorites": "Is fearr leat amháin",
"open": "Oscail",
"open_calendar": "Oscail an féilire",
"open_in_map_view": "Oscail i radharc léarscáile",
"open_in_openstreetmap": "Oscail in OpenStreetMap",
"open_the_search_filters": "Oscail na scagairí cuardaigh",
@@ -2183,6 +2190,7 @@
"support": "Tacaíocht",
"support_and_feedback": "Tacaíocht & Aiseolas",
"support_third_party_description": "Rinne tríú páirtí pacáiste de do shuiteáil Immich. Dfhéadfadh sé gur an pacáiste sin ba chúis le fadhbanna a bhíonn agat, mar sin tabhair ceisteanna dóibh ar dtús trí na naisc thíos a úsáid.",
"supporter": "Tacaíochtaí",
"swap_merge_direction": "Malartaigh treo an chumaisc",
"sync": "Sioncrónaigh",
"sync_albums": "Sioncrónaigh albaim",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Cerca Attività…",
"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_external_domain_settings_description": "Dominio utilizzato per i link esterni",
"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",
@@ -794,6 +794,11 @@
"color": "Colore",
"color_theme": "Colore Tema",
"command": "Comando",
"command_palette_prompt": "Trova rapidamente pagine, azioni o comandi",
"command_palette_to_close": "per chiudere",
"command_palette_to_navigate": "per entrare",
"command_palette_to_select": "per selezionare",
"command_palette_to_show_all": "per mostrare tutto",
"comment_deleted": "Commento eliminato",
"comment_options": "Opzioni per i commenti",
"comments_and_likes": "Commenti & mi piace",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "PERSONE",
"exif_bottom_sheet_person_add_person": "Aggiungi nome",
"exit_slideshow": "Esci dalla presentazione",
"expand": "Espandi",
"expand_all": "Espandi tutto",
"experimental_settings_new_asset_list_subtitle": "Lavori in corso",
"experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale",
@@ -1642,6 +1648,7 @@
"online": "Online",
"only_favorites": "Solo preferiti",
"open": "Apri",
"open_calendar": "Apri il calendario",
"open_in_map_view": "Apri nella visualizzazione mappa",
"open_in_openstreetmap": "Apri su OpenStreetMap",
"open_the_search_filters": "Apri filtri di ricerca",
@@ -2183,6 +2190,7 @@
"support": "Supporto",
"support_and_feedback": "Supporto & Feedback",
"support_third_party_description": "La tua installazione di Immich è stata costruita da terze parti. I problemi che riscontri potrebbero essere causati da altri pacchetti, quindi ti preghiamo di sollevare il problema in prima istanza utilizzando i link sottostanti.",
"supporter": "Sostenitore",
"swap_merge_direction": "Scambia direzione di unione",
"sync": "Sincronizza",
"sync_albums": "Sincronizza album",

View File

@@ -311,7 +311,7 @@
"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)://",
"server_external_domain_settings_description": "Domein voor externe links",
"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",
@@ -793,7 +793,12 @@
"collapse_all": "Alles inklappen",
"color": "Kleur",
"color_theme": "Kleurenthema",
"command": "Opdracht",
"command": "Commando",
"command_palette_prompt": "Vind snel pagina's, acties of commando's",
"command_palette_to_close": "om te sluiten",
"command_palette_to_navigate": "om te navigeren",
"command_palette_to_select": "om te selecteren",
"command_palette_to_show_all": "om alles te tonen",
"comment_deleted": "Opmerking verwijderd",
"comment_options": "Opties voor opmerkingen",
"comments_and_likes": "Opmerkingen & likes",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "MENSEN",
"exif_bottom_sheet_person_add_person": "Naam toevoegen",
"exit_slideshow": "Diavoorstelling sluiten",
"expand": "Uitklappen",
"expand_all": "Alles uitvouwen",
"experimental_settings_new_asset_list_subtitle": "Werk in uitvoering",
"experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen",
@@ -1642,6 +1648,7 @@
"online": "Online",
"only_favorites": "Alleen favorieten",
"open": "Openen",
"open_calendar": "Open kalender",
"open_in_map_view": "Openen in kaartweergave",
"open_in_openstreetmap": "Openen in OpenStreetMap",
"open_the_search_filters": "Open de zoekfilters",
@@ -2183,6 +2190,7 @@
"support": "Ondersteuning",
"support_and_feedback": "Ondersteuning & feedback",
"support_third_party_description": "Je Immich installatie is door een derde partij samengesteld. Problemen die je ervaart, kunnen door dat pakket veroorzaakt zijn. Meld problemen in eerste instantie bij hen via de onderstaande links.",
"supporter": "Supporter",
"swap_merge_direction": "Wissel richting voor samenvoegen om",
"sync": "Synchroniseren",
"sync_albums": "Albums synchroniseren",
@@ -2294,7 +2302,7 @@
"unstack_action_prompt": "{count} item(s) ontstapeld",
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapeld",
"unsupported_field_type": "Veldtype niet ondersteund",
"untagged": "Ongemarkeerd",
"untagged": "Zonder tags",
"untitled_workflow": "Naamloze werkstroom",
"up_next": "Volgende",
"update_location_action_prompt": "Werk de locatie bij van {count} geselecteerde items met:",

View File

@@ -2064,7 +2064,7 @@
"shared_by_you": "Udostępnione przez ciebie",
"shared_from_partner": "Zdjęcia od {partner}",
"shared_intent_upload_button_progress_text": "{current} / {total} Przesłano",
"shared_link_app_bar_title": "Udostępnione linki",
"shared_link_app_bar_title": "Udostępnione",
"shared_link_clipboard_copied_massage": "Skopiowane do schowka",
"shared_link_clipboard_text": "Link: {link}\nHasło: {password}",
"shared_link_create_error": "Błąd podczas tworzenia linka do udostępnienia",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Поиск задач…",
"send_welcome_email": "Отправить приветственное письмо",
"server_external_domain_settings": "Внешний домен",
"server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://",
"server_external_domain_settings_description": "Домен для публичных ссылок",
"server_public_users": "Публичные пользователи",
"server_public_users_description": "Выводить список пользователей (имена и email) в общих альбомах. Когда отключено, список доступен только администраторам, пользователи смогут делиться только ссылкой.",
"server_settings": "Настройки сервера",
@@ -794,6 +794,11 @@
"color": "Цвет",
"color_theme": "Цветовая тема",
"command": "Команда",
"command_palette_prompt": "Быстрый поиск страниц, действий или команд",
"command_palette_to_close": "закрыть",
"command_palette_to_navigate": "навигация",
"command_palette_to_select": "выбрать",
"command_palette_to_show_all": "показать все",
"comment_deleted": "Комментарий удалён",
"comment_options": "Действия с комментарием",
"comments_and_likes": "Комментарии и отметки \"нравится\"",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "ЛЮДИ",
"exif_bottom_sheet_person_add_person": "Добавить имя",
"exit_slideshow": "Выйти из слайд-шоу",
"expand": "Развернуть",
"expand_all": "Развернуть всё",
"experimental_settings_new_asset_list_subtitle": "В разработке",
"experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий",
@@ -1642,6 +1648,7 @@
"online": "Доступен",
"only_favorites": "Только избранное",
"open": "Открыть",
"open_calendar": "Открыть календарь",
"open_in_map_view": "Открыть в режиме просмотра карты",
"open_in_openstreetmap": "Открыть в OpenStreetMap",
"open_the_search_filters": "Открыть фильтры поиска",
@@ -2128,7 +2135,7 @@
"show_search_options": "Показать параметры поиска",
"show_shared_links": "Показать публичные ссылки",
"show_slideshow_transition": "Плавный переход",
"show_supporter_badge": "Значок поддержки",
"show_supporter_badge": "Значок спонсорства",
"show_supporter_badge_description": "Показать значок поддержки",
"show_text_recognition": "Показать распознанный текст",
"show_text_search_menu": "Показать меню текстового поиска",
@@ -2183,6 +2190,7 @@
"support": "Поддержка",
"support_and_feedback": "Поддержка и обратная связь",
"support_third_party_description": "Ваша установка immich была упакована сторонним разработчиком. Проблемы, с которыми вы столкнулись, могут быть вызваны этим пакетом, поэтому, пожалуйста, в первую очередь обращайтесь к ним, используя ссылки ниже.",
"supporter": "Спонсор Immich",
"swap_merge_direction": "Изменить направление слияния",
"sync": "Синхр.",
"sync_albums": "Синхронизировать альбомы",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Vyhľadať úlohy…",
"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_external_domain_settings_description": "Doména používaná pre externé odkazy",
"server_public_users": "Verejní používatelia",
"server_public_users_description": "Všetci používatelia (meno a email) sú uvedení pri pridávaní používateľa do zdieľaných albumov. Ak je táto funkcia vypnutá, zoznam používateľov bude dostupný iba správcom.",
"server_settings": "Server",
@@ -794,6 +794,11 @@
"color": "Farba",
"color_theme": "Farba témy",
"command": "Príkaz",
"command_palette_prompt": "Rýchlo vyhľadajte stránky, akcie alebo príkazy ako",
"command_palette_to_close": "zatvoriť",
"command_palette_to_navigate": "vložiť",
"command_palette_to_select": "vybrať",
"command_palette_to_show_all": "zobraziť všetko",
"comment_deleted": "Komentár bol odstránený",
"comment_options": "Možnosti komentára",
"comments_and_likes": "Komentáre a páči sa mi to",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "ĽUDIA",
"exif_bottom_sheet_person_add_person": "Pridať meno",
"exit_slideshow": "Opustiť prezentáciu",
"expand": "Rozbaliť",
"expand_all": "Rozbaliť všetko",
"experimental_settings_new_asset_list_subtitle": "Prebiehajúca práca",
"experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií",
@@ -1642,6 +1648,7 @@
"online": "Online",
"only_favorites": "Len obľúbené",
"open": "Otvoriť",
"open_calendar": "Otvoriť kalendár",
"open_in_map_view": "Otvoriť v mape",
"open_in_openstreetmap": "Otvoriť v OpenStreetMap",
"open_the_search_filters": "Otvoriť vyhľadávacie filtre",
@@ -2183,6 +2190,7 @@
"support": "Podpora",
"support_and_feedback": "Podpora a spätná väzba",
"support_third_party_description": "Vaša inštalácia Immich bola pripravená treťou stranou. Problémy, ktoré sa vyskytli, môžu byť spôsobené týmto balíčkom, preto sa na nich obráťte v prvom rade cez nasledujúce odkazy.",
"supporter": "Podporovateľ",
"swap_merge_direction": "Vymeniť smer zlúčenia",
"sync": "Synchronizovať",
"sync_albums": "Synchronizovať albumy",

View File

@@ -311,7 +311,7 @@
"search_jobs": "Išči opravila…",
"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_external_domain_settings_description": "Domena, uporabljena za zunanje povezave",
"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",
@@ -794,6 +794,11 @@
"color": "Barva",
"color_theme": "Barva teme",
"command": "Ukaz",
"command_palette_prompt": "Hitro iskanje strani, dejanj ali ukazov",
"command_palette_to_close": "zapreti",
"command_palette_to_navigate": "vstopiti",
"command_palette_to_select": "izbrati",
"command_palette_to_show_all": "prikazati vse",
"comment_deleted": "Komentar izbrisan",
"comment_options": "Možnosti komentiranja",
"comments_and_likes": "Komentarji in všečki",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "OSEBE",
"exif_bottom_sheet_person_add_person": "Dodaj ime",
"exit_slideshow": "Zapustite diaprojekcijo",
"expand": "Razširi",
"expand_all": "Razširi vse",
"experimental_settings_new_asset_list_subtitle": "Delo v teku",
"experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij",
@@ -1642,6 +1648,7 @@
"online": "Povezano",
"only_favorites": "Samo priljubljene",
"open": "Odpri",
"open_calendar": "Odpri koledar",
"open_in_map_view": "Odpri v pogledu zemljevida",
"open_in_openstreetmap": "Odpri v OpenStreetMap",
"open_the_search_filters": "Odpri iskalne filtre",
@@ -2183,6 +2190,7 @@
"support": "Podpora",
"support_and_feedback": "Podpora in povratne informacije",
"support_third_party_description": "Vašo namestitev Immich je pakirala tretja oseba. Težave, ki jih imate, lahko povzroči ta paket, zato prosimo, da težave najprej izpostavite njim, tako da uporabite spodnje povezave.",
"supporter": "Podpornik",
"swap_merge_direction": "Zamenjaj smer združevanja",
"sync": "Sinhronizacija",
"sync_albums": "Sinhronizacija albumov",

View File

@@ -6,8 +6,8 @@
"action": "ดำเนินการ",
"action_common_update": "อัปเดต",
"actions": "การดำเนินการ",
"active": "ใช้งานอยู่",
"active_count": "ใช้งานอยู่: {count}",
"active": "กำลังทำงาน",
"active_count": "กำลังทำงาน: {count}",
"activity": "กิจกรรม",
"activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่",
"add": "เพิ่ม",
@@ -27,7 +27,7 @@
"add_path": "เพิ่มพาทที่ตั้ง",
"add_photos": "เพิ่มรูปภาพ",
"add_tag": "เพิ่มแท็ก",
"add_to": "เพิ่มไปยัง …",
"add_to": "เพิ่มไปยัง…",
"add_to_album": "เพิ่มไปยังอัลบั้ม",
"add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album} แล้ว",
"add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว",
@@ -200,7 +200,7 @@
"metadata_settings": "การตั้งค่า Metadata",
"metadata_settings_description": "จัดการการตั้งค่า Metadata",
"migration_job": "การโยกย้าย",
"migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
"migration_job_description": "ย้ายภาพตัวอย่างสำหรับสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
"nightly_tasks_cluster_new_faces_setting": "คลัสเตอร์ใบหน้าใหม่",
"nightly_tasks_generate_memories_setting": "สร้างความทรงจำ",
"nightly_tasks_generate_memories_setting_description": "สร้างความทรงจำใหม่จากสื่อ",
@@ -526,20 +526,20 @@
"asset_viewer_settings_subtitle": "ตั้งค่าการแสดงแกลเลอรี",
"asset_viewer_settings_title": "ตัวดูทรัพยากร",
"assets": "สื่อ",
"assets_added_count": "เพิ่ม {count, plural, one{# สื่อ} other {# สื่อ}} แล้ว",
"assets_added_count": "เพิ่มสื่อ {count, plural, one{# รายการ} other {# รายการ}}แล้ว",
"assets_added_to_album_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยังอัลบั้ม",
"assets_added_to_albums_count": "เพิ่มสื่อ {assetTotal, plural, one {# รายการ} other {# รายการ}} ไปยังอัลบั้ม {albumTotal, plural, one {# รายการ} other {# รายการ}}แล้ว",
"assets_cannot_be_added_to_album_count": "ไม่สามารถเพิ่ม {count, plural, one {สื่อ} other {สื่อ}} ไปยังอัลบั้ม",
"assets_cannot_be_added_to_albums": "ไม่สามารถเพิ่ม{count, plural, one {สื่อ} other {สื่อ}}ไปยังอัลบั้มใด ๆ ได้",
"assets_count": "{count, plural, one { สื่อ} other { สื่อ}}",
"assets_count": "สื่อ {count, plural, one {# รายการ} other {# รายการ}}",
"assets_deleted_permanently": "{count} สื่อถูกลบอย่างถาวร",
"assets_deleted_permanently_from_server": "ลบ {count} สื่อออกจาก Immich อย่างถาวร",
"assets_deleted_permanently_from_server": "ลบสื่อ {count} รายการออกจากเซิร์ฟเวอร์ Immich อย่างถาวรแล้ว",
"assets_downloaded_failed": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} ไม่สำเร็จ - {error}",
"assets_downloaded_successfully": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} สำเร็จ",
"assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว",
"assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร",
"assets_removed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบแล้ว",
"assets_removed_permanently_from_device": "นำ {count} สื่อออกจากอุปกรณ์อย่างถาวร",
"assets_removed_permanently_from_device": "ลบสื่อ {count} รายการออกจากอุปกรณ์ของคุณอย่างถาวรแล้ว",
"assets_restore_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนสื่อที่ทิ้งทั้งหมด? คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้! โปรดทราบว่าสื่อออฟไลน์ใดๆ ไม่สามารถกู้คืนได้ด้วยวิธีนี้",
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า",
"assets_restored_successfully": "กู้คืน {count} สื่อสำเร็จ",
@@ -699,6 +699,8 @@
"cleanup_found_assets": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว",
"cleanup_found_assets_with_size": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว ({size})",
"cleanup_icloud_shared_albums_excluded": "อัลบั้มที่แชร์บน iCloud ไม่นับรวมในการค้นหา",
"cleanup_no_assets_found": "ไม่พบสื่อที่ตรงตามเงื่อนไขด้านบน \"เพิ่มพื้นที่ว่าง\" สามารถลบได้เฉพาะสื่อที่สำรองข้อมูลบนเซิร์ฟเวอร์เรียบร้อยแล้วเท่านั้น",
"cleanup_preview_title": "สื่อที่จะลบ ({count})",
"clear": "ล้าง",
"clear_all": "ล้างทั้งหมด",
"clear_all_recent_searches": "ล้างประวัติการค้นหา",
@@ -1062,7 +1064,7 @@
"export_as_json": "ส่งออกเป็นไฟล์ JSON",
"extension": "ส่วนต่อขยาย",
"external": "ภายนอก",
"external_libraries": "ภายนอกคลังภาพ",
"external_libraries": "คลังภาพภายนอก",
"external_network": "การเชื่อมต่อภายนอก",
"external_network_sheet_info": "เมื่อไม่ได้เชื่อมต่อ Wi-Fi ที่เลือกไว้ แอพจะเชื่อมต่อเซิร์ฟเวอร์ผ่าน URL ด้านล่างตามลำดับ",
"face_unassigned": "ไม่กำหนดมอบหมาย",
@@ -1658,8 +1660,8 @@
"server_endpoint": "ปลายทางเซิร์ฟเวอร์",
"server_info_box_app_version": "เวอร์ชันแอพ",
"server_info_box_server_url": "URL เซิร์ฟเวอร์",
"server_offline": "Server ออฟไลน์",
"server_online": "Server ออนไลน์",
"server_offline": "เซิร์ฟเวอร์ออฟไลน์",
"server_online": "เซิร์ฟเวอร์ออนไลน์",
"server_privacy": "ความเป็นส่วนตัวเซิร์ฟเวอร์",
"server_stats": "สถิติเซิร์ฟเวอร์",
"server_version": "เวอร์ชันของเซิร์ฟเวอร์",

View File

@@ -41,6 +41,7 @@
"add_to_bottom_bar": "加至",
"add_to_shared_album": "加至共享相簿",
"add_url": "加網址",
"add_workflow_step": "增加工作步驟",
"added_to_favorites": "已加至最愛",
"added_to_favorites_count": "已加{count, number} 個項目至最愛",
"admin": {

View File

@@ -311,7 +311,7 @@
"search_jobs": "搜尋任務…",
"send_welcome_email": "傳送歡迎電子郵件",
"server_external_domain_settings": "外部網域",
"server_external_domain_settings_description": "公開分享連結的網域,包含 http(s)://",
"server_external_domain_settings_description": "公開分享連結的網域",
"server_public_users": "公開使用者",
"server_public_users_description": "將使用者新增至共享相簿時,會列出所有使用者(姓名與電子郵件)。若停用,使用者清單將僅供管理員查看。",
"server_settings": "伺服器設定",
@@ -794,6 +794,11 @@
"color": "顏色",
"color_theme": "色彩主題",
"command": "命令",
"command_palette_prompt": "快速尋找頁面,動作或者指令",
"command_palette_to_close": "關閉",
"command_palette_to_navigate": "輸入",
"command_palette_to_select": "選擇",
"command_palette_to_show_all": "顯示全部",
"comment_deleted": "留言已刪除",
"comment_options": "留言選項",
"comments_and_likes": "留言與喜歡",
@@ -1168,6 +1173,7 @@
"exif_bottom_sheet_people": "人物",
"exif_bottom_sheet_person_add_person": "新增姓名",
"exit_slideshow": "結束幻燈片",
"expand": "展開",
"expand_all": "展開全部",
"experimental_settings_new_asset_list_subtitle": "正在處理",
"experimental_settings_new_asset_list_title": "啟用實驗性相片格狀版面",
@@ -1642,6 +1648,7 @@
"online": "線上",
"only_favorites": "僅顯示己收藏",
"open": "開啟",
"open_calendar": "打開日曆",
"open_in_map_view": "開啟地圖檢視",
"open_in_openstreetmap": "用 OpenStreetMap 開啟",
"open_the_search_filters": "開啟搜尋篩選器",
@@ -1916,7 +1923,7 @@
"search_by_description_example": "在沙壩的健行之日",
"search_by_filename": "依檔名或副檔名搜尋",
"search_by_filename_example": "如 IMG_1234.JPG 或 PNG",
"search_by_ocr": "過OCR蒐索",
"search_by_ocr": "過OCR搜尋",
"search_by_ocr_example": "拿鐵",
"search_camera_lens_model": "蒐索鏡頭型號...",
"search_camera_make": "搜尋相機製造商…",
@@ -1935,7 +1942,7 @@
"search_filter_location_title": "選擇位置",
"search_filter_media_type": "媒體類型",
"search_filter_media_type_title": "選擇媒體類型",
"search_filter_ocr": "過OCR蒐索",
"search_filter_ocr": "過OCR搜尋",
"search_filter_people_title": "選擇人物",
"search_filter_star_rating": "評分",
"search_for": "搜尋",
@@ -2183,6 +2190,7 @@
"support": "支援",
"support_and_feedback": "支援與回饋",
"support_third_party_description": "您安裝的 Immich 是由第三方打包的。您遇到的問題可能是該套件造成的,所以請先使用下面的連結向他們提出問題。",
"supporter": "支持者",
"swap_merge_direction": "交換合併方向",
"sync": "同步",
"sync_albums": "同步相簿",

View File

@@ -117,7 +117,7 @@
android:pathPrefix="/albums/" />
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
android:pathPrefix="/people/" />
<data
android:host="my.immich.app"
android:path="/memory" />

View File

@@ -10,6 +10,10 @@ class DriftPeopleService {
const DriftPeopleService(this._repository, this._personApiRepository);
Future<DriftPerson?> get(String personId) {
return _repository.get(personId);
}
Future<List<DriftPerson>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}

View File

@@ -1,4 +1,5 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -7,6 +8,13 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPeopleRepository(this._db) : super(_db);
Future<DriftPerson?> get(String personId) async {
final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId));
final result = await query.getSingleOrNull();
return result?.toDto();
}
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
final query = _db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
@@ -19,19 +27,28 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
}
Future<List<DriftPerson>> getAllPeople() async {
final people = _db.personEntity;
final faces = _db.assetFaceEntity;
final assets = _db.remoteAssetEntity;
final query =
_db.select(_db.personEntity).join([
leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)),
_db.select(people).join([
innerJoin(faces, faces.personId.equalsExp(people.id)),
innerJoin(assets, assets.id.equalsExp(faces.assetId)),
])
..where(_db.personEntity.isHidden.equals(false))
..groupBy([_db.personEntity.id], having: _db.assetFaceEntity.id.count().isBiggerOrEqualValue(3))
..where(
people.isHidden.equals(false) &
assets.deletedAt.isNull() &
assets.visibility.equalsValue(AssetVisibility.timeline),
)
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
..orderBy([
OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc),
OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc),
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
]);
return query.map((row) {
final person = row.readTable(_db.personEntity);
final person = row.readTable(people);
return person.toDto();
}).get();
}

View File

@@ -28,7 +28,7 @@ class ServerInfo {
const ServerInfo({
required this.serverVersion,
required this.latestVersion,
this.latestVersion,
required this.serverFeatures,
required this.serverConfig,
required this.serverDiskInfo,

View File

@@ -1,27 +1,45 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double dimension;
class TimelineRow extends MultiChildRenderObjectWidget {
final double height;
final List<double> widths;
final double spacing;
final TextDirection textDirection;
const FixedTimelineRow({
const TimelineRow({
super.key,
required this.dimension,
required this.height,
required this.widths,
required this.spacing,
required this.textDirection,
required super.children,
});
factory TimelineRow.fixed({
required double dimension,
required double spacing,
required TextDirection textDirection,
required List<Widget> children,
}) => TimelineRow(
height: dimension,
widths: List.filled(children.length, dimension),
spacing: spacing,
textDirection: textDirection,
children: children,
);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
}
@override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension;
renderObject.height = height;
renderObject.widths = widths;
renderObject.spacing = spacing;
renderObject.textDirection = textDirection;
}
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double dimension,
required double height,
required List<double> widths,
required double spacing,
required TextDirection textDirection,
}) : _dimension = dimension,
}) : _height = height,
_widths = widths,
_spacing = spacing,
_textDirection = textDirection {
addAll(children);
}
double get dimension => _dimension;
double _dimension;
double get height => _height;
double _height;
set dimension(double value) {
if (_dimension == value) return;
_dimension = value;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
List<double> get widths => _widths;
List<double> _widths;
set widths(List<double> value) {
if (listEquals(_widths, value)) return;
_widths = value;
markNeedsLayout();
}
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
}
}
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => dimension;
double computeMinIntrinsicHeight(double width) => height;
@override
double computeMaxIntrinsicHeight(double width) => dimension;
double computeMaxIntrinsicHeight(double width) => height;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
return;
}
// Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
size = Size(constraints.maxWidth, height);
final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
int childIndex = 0;
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
// Layout each child horizontally.
while (child != null) {
while (child != null && childIndex < widths.length) {
final width = widths[childIndex];
final childConstraints = BoxConstraints.tight(Size(width, height));
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset;
offset += Offset(dx, 0);
childParentData.offset = Offset(currentX, 0);
child = childParentData.nextSibling;
childIndex++;
if (child != null && childIndex < widths.length) {
final nextWidth = widths[childIndex];
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
}
}
}
}

View File

@@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
@@ -78,6 +80,7 @@ class FixedSegment extends Segment {
assetCount: numberOfAssets,
tileHeight: tileHeight,
spacing: spacing,
columnCount: columnCount,
);
}
}
@@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget {
final int assetCount;
final double tileHeight;
final double spacing;
final int columnCount;
const _FixedSegmentRow({
required this.assetIndex,
required this.assetCount,
required this.tileHeight,
required this.spacing,
required this.columnCount,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
if (isScrubbing) {
return _buildPlaceholder(context);
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
timelineService,
isDynamicLayout,
);
}
return FutureBuilder<List<BaseAsset>>(
@@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData, timelineService);
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
}
@@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
Widget _buildAssetRow(
BuildContext context,
List<BaseAsset> assets,
TimelineService timelineService,
bool isDynamicLayout,
) {
final children = [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
),
];
final widths = List.filled(assets.length, tileHeight);
if (isDynamicLayout) {
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / assets.length;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize to get width distribution
final sum = arConfiguration.sum;
int index = 0;
for (final ratio in arConfiguration) {
// Distribute the available width proportionally based on aspect ratio configuration
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
}
}
return TimelineDragRegion(
child: TimelineRow(
height: tileHeight,
widths: widths,
spacing: spacing,
textDirection: Directionality.of(context),
children: children,
),
);
}
}

View File

@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing,
}) => RepaintBoundary(
child: FixedTimelineRow(
child: TimelineRow.fixed(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),

View File

@@ -15,7 +15,6 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
: super(
const ServerInfo(
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
latestVersion: null,
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
serverConfig: ServerConfig(
trashDays: 30,
@@ -104,7 +103,9 @@ final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfo>
final versionWarningPresentProvider = Provider.family<bool, UserDto?>((ref, user) {
final serverInfo = ref.watch(serverInfoProvider);
return serverInfo.versionStatus == VersionStatus.clientOutOfDate ||
serverInfo.versionStatus == VersionStatus.error ||
((user?.isAdmin ?? false) && serverInfo.versionStatus == VersionStatus.serverOutOfDate);
return switch (serverInfo.versionStatus) {
VersionStatus.clientOutOfDate || VersionStatus.error => true,
VersionStatus.serverOutOfDate => serverInfo.latestVersion != null && (user?.isAdmin ?? false),
VersionStatus.upToDate => false,
};
});

View File

@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service;
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -13,6 +14,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -33,6 +35,7 @@ final deepLinkServiceProvider = Provider(
ref.watch(beta_asset_provider.assetServiceProvider),
ref.watch(remoteAlbumServiceProvider),
ref.watch(driftMemoryServiceProvider),
ref.watch(driftPeopleServiceProvider),
ref.watch(currentUserProvider),
),
);
@@ -49,7 +52,8 @@ class DeepLinkService {
final TimelineFactory _betaTimelineFactory;
final beta_asset_service.AssetService _betaAssetService;
final RemoteAlbumService _betaRemoteAlbumService;
final DriftMemoryService _betaMemoryServiceProvider;
final DriftMemoryService _betaMemoryService;
final DriftPeopleService _betaPeopleService;
final UserDto? _currentUser;
@@ -62,7 +66,8 @@ class DeepLinkService {
this._betaTimelineFactory,
this._betaAssetService,
this._betaRemoteAlbumService,
this._betaMemoryServiceProvider,
this._betaMemoryService,
this._betaPeopleService,
this._currentUser,
);
@@ -84,6 +89,7 @@ class DeepLinkService {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''),
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
_ => null,
};
@@ -106,6 +112,7 @@ class DeepLinkService {
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
final assetRegex = RegExp('/photos/($uuidRegex)');
final albumRegex = RegExp('/albums/($uuidRegex)');
final peopleRegex = RegExp('/people/($uuidRegex)');
PageRouteInfo<dynamic>? deepLinkRoute;
if (assetRegex.hasMatch(path)) {
@@ -114,6 +121,9 @@ class DeepLinkService {
} else if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAlbumDeepLink(albumId);
} else if (peopleRegex.hasMatch(path)) {
final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildPeopleDeepLink(peopleId);
} else if (path == "/memory") {
deepLinkRoute = await _buildMemoryDeepLink(null);
}
@@ -136,9 +146,9 @@ class DeepLinkService {
return null;
}
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
memories = await _betaMemoryService.getMemoryLane(_currentUser.id);
} else {
final memory = await _betaMemoryServiceProvider.get(memoryId);
final memory = await _betaMemoryService.get(memoryId);
if (memory != null) {
memories = [memory];
}
@@ -225,4 +235,18 @@ class DeepLinkService {
return DriftActivitiesRoute(album: album);
}
Future<PageRouteInfo?> _buildPeopleDeepLink(String personId) async {
if (Store.isBetaTimelineEnabled == false) {
return null;
}
final person = await _betaPeopleService.get(personId);
if (person == null) {
return null;
}
return DriftPersonRoute(person: person);
}
}

View File

@@ -23,8 +23,6 @@ class AppBarServerInfo extends HookConsumerWidget {
final bool showVersionWarning = ref.watch(versionWarningPresentProvider(user));
final appInfo = useState({});
const titleFontSize = 12.0;
const contentFontSize = 11.0;
getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
@@ -37,176 +35,38 @@ class AppBarServerInfo extends HookConsumerWidget {
return null;
}, []);
const divider = Divider(thickness: 1);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (showVersionWarning) ...[
const Padding(padding: EdgeInsets.symmetric(horizontal: 8.0), child: ServerUpdateNotification()),
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_app_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
),
],
if (showVersionWarning) ...[const ServerUpdateNotification(), divider],
_ServerInfoItem(
label: "server_info_box_app_version".tr(),
text: "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_server_url".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
flex: 0,
child: Container(
width: 200,
padding: const EdgeInsets.only(right: 10.0),
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: context.primaryColor.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
textStyle: TextStyle(
color: context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
message: getServerUrl() ?? '--',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: Text(
getServerUrl() ?? '--',
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
),
),
),
),
],
divider,
_ServerInfoItem(
label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
),
divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
if (serverInfoState.latestVersion != null) ...[
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Row(
children: [
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
const Padding(
padding: EdgeInsets.only(right: 5.0),
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
),
Text(
"latest_version".tr(),
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
),
],
divider,
_ServerInfoItem(
label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
: null,
),
],
],
@@ -214,3 +74,64 @@ class AppBarServerInfo extends HookConsumerWidget {
);
}
}
class _ServerInfoItem extends StatelessWidget {
final String label;
final String text;
final bool tooltip;
final Icon? icon;
static const titleFontSize = 12.0;
static const contentFontSize = 11.0;
const _ServerInfoItem({required this.label, required this.text, this.tooltip = false, this.icon});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (icon != null) ...[icon as Widget, const SizedBox(width: 8)],
Text(
label,
style: TextStyle(
fontSize: titleFontSize,
color: context.textTheme.labelSmall?.color,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Expanded(
child: _maybeTooltip(
context,
Text(
text,
style: TextStyle(
fontSize: contentFontSize,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
),
),
),
],
);
}
Widget _maybeTooltip(BuildContext context, Widget child) => tooltip
? Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: context.primaryColor.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
textStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.bold),
message: text,
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
child: child,
)
: child;
}

View File

@@ -1,6 +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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget {
title: "asset_list_layout_sub_title".t(context: context),
icon: Icons.view_module_outlined,
),
SettingsSwitchListTile(
valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSliderListTile(
valueNotifier: tilesPerRow,
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),

View File

@@ -1217,10 +1217,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1910,10 +1910,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:

339
pnpm-lock.yaml generated
View File

@@ -67,7 +67,7 @@ importers:
version: 24.10.13
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -115,10 +115,10 @@ importers:
version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
yaml:
specifier: ^2.3.1
version: 2.8.2
@@ -343,6 +343,9 @@ importers:
'@extism/extism':
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@nestjs/bullmq':
specifier: ^11.0.1
version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3)
'@nestjs/common':
specifier: ^11.0.4
version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -403,9 +406,12 @@ importers:
'@react-email/render':
specifier: ^1.1.2
version: 1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.6)
'@socket.io/postgres-adapter':
specifier: ^0.5.0
version: 0.5.0(socket.io-adapter@2.5.6)
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
ajv:
specifier: ^8.17.1
version: 8.17.1
@@ -421,6 +427,9 @@ importers:
body-parser:
specifier: ^2.2.0
version: 2.2.2
bullmq:
specifier: ^5.51.0
version: 5.67.3
chokidar:
specifier: ^4.0.3
version: 4.0.3
@@ -556,6 +565,9 @@ importers:
socket.io:
specifier: ^4.8.1
version: 4.8.3
socket.io-adapter:
specifier: ^2.5.6
version: 2.5.6
tailwindcss-preset-email:
specifier: ^1.4.0
version: 1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
@@ -664,7 +676,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
eslint:
specifier: ^9.14.0
version: 9.39.2(jiti@2.6.1)
@@ -721,7 +733,7 @@ importers:
version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
web:
dependencies:
@@ -775,7 +787,7 @@ importers:
version: 2.6.0
fabric:
specifier: ^6.5.4
version: 6.9.1(encoding@0.1.13)
version: 6.9.1
geo-coordinates-parser:
specifier: ^1.7.4
version: 1.7.4
@@ -875,7 +887,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -899,7 +911,7 @@ importers:
version: 1.5.6
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
dotenv:
specifier: ^17.0.0
version: 17.2.4
@@ -962,7 +974,7 @@ importers:
version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
packages:
@@ -3396,9 +3408,56 @@ packages:
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@msgpack/msgpack@2.8.0':
resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==}
engines: {node: '>= 10'}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
cpu: [x64]
os: [win32]
'@namnode/store@0.1.0':
resolution: {integrity: sha512-4NGTldxKcmY0UuZ7OEkvCjs8ZEoeYB6M2UwMu74pdLiFMKxXbj9HdNk1Qn213bxX1O7bY5h+PLh5DZsTURZkYA==}
'@nestjs/bull-shared@11.0.4':
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/bullmq@11.0.4':
resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
'@nestjs/cli@11.0.16':
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
engines: {node: '>= 20.11'}
@@ -4261,9 +4320,9 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@socket.io/redis-adapter@8.3.0':
resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==}
engines: {node: '>=10.0.0'}
'@socket.io/postgres-adapter@0.5.0':
resolution: {integrity: sha512-s1vFsatB4lS429ZbeAi8ju+mZMgtgdSmi9UsZsdcEG++vVtX5z10yDEt4TV8saePscvvGjs6uXvJfMCxz8+M2Q==}
engines: {node: '>=12.0.0'}
peerDependencies:
socket.io-adapter: ^2.5.4
@@ -5695,6 +5754,9 @@ packages:
resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
engines: {node: '>=18.20'}
bullmq@5.67.3:
resolution: {integrity: sha512-eeQobOJn8M0Rj8tcZCVFLrimZgJQallJH1JpclOoyut2nDNkDwTEPMVcZzLeSR2fGeIVbfJTjU96F563Qkge5A==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -6189,6 +6251,10 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
@@ -9048,6 +9114,13 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.3:
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
hasBin: true
msgpackr@1.11.5:
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
multer@2.0.2:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
@@ -9177,6 +9250,10 @@ packages:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -9218,9 +9295,6 @@ packages:
not@0.1.0:
resolution: {integrity: sha512-5PDmaAsVfnWUgTUbJ3ERwn7u79Z0dYxN9ErxCpVJJqe2RK0PJ3z+iFUxuqjwtlDDegXvtWoxD/3Fzxox7tFGWA==}
notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -10686,6 +10760,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -11547,10 +11626,6 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
uid2@1.0.0:
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
engines: {node: '>= 4.0.0'}
uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'}
@@ -15219,6 +15294,22 @@ snapshots:
dependencies:
mapbox-gl: 1.13.3
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
dependencies:
detect-libc: 2.1.2
@@ -15338,8 +15429,42 @@ snapshots:
'@microsoft/tsdoc@0.16.0': {}
'@msgpack/msgpack@2.8.0': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@namnode/store@0.1.0': {}
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
'@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3)':
dependencies:
'@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
bullmq: 5.67.3
tslib: 2.8.1
'@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
@@ -16183,13 +16308,15 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)':
'@socket.io/postgres-adapter@0.5.0(socket.io-adapter@2.5.6)':
dependencies:
'@msgpack/msgpack': 2.8.0
'@types/pg': 8.16.0
debug: 4.3.7
notepack.io: 3.0.1
pg: 8.18.0
socket.io-adapter: 2.5.6
uid2: 1.0.0
transitivePeerDependencies:
- pg-native
- supports-color
'@sphinxxxx/color-conversion@2.2.2': {}
@@ -16507,14 +16634,14 @@ snapshots:
dependencies:
svelte: 5.50.0
'@testing-library/svelte@5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@testing-library/svelte@5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.50.0)
svelte: 5.50.0
optionalDependencies:
vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17210,7 +17337,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17225,11 +17352,11 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17244,7 +17371,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -17867,6 +17994,18 @@ snapshots:
builtin-modules@5.0.0: {}
bullmq@5.67.3:
dependencies:
cron-parser: 4.9.0
ioredis: 5.9.2
msgpackr: 1.11.5
node-abort-controller: 3.1.1
semver: 7.7.3
tslib: 2.8.1
uuid: 11.1.0
transitivePeerDependencies:
- supports-color
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
@@ -17952,6 +18091,16 @@ snapshots:
caniuse-lite@1.0.30001769: {}
canvas@2.11.2:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.24.0
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
optional: true
canvas@2.11.2(encoding@0.1.13):
dependencies:
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
@@ -18345,6 +18494,10 @@ snapshots:
crelt@1.0.6: {}
cron-parser@4.9.0:
dependencies:
luxon: 3.7.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
@@ -19568,10 +19721,10 @@ snapshots:
extend@3.0.2: {}
fabric@6.9.1(encoding@0.1.13):
fabric@6.9.1:
optionalDependencies:
canvas: 2.11.2(encoding@0.1.13)
jsdom: 20.0.3(canvas@2.11.2(encoding@0.1.13))
canvas: 2.11.2
jsdom: 20.0.3(canvas@2.11.2)
transitivePeerDependencies:
- bufferutil
- encoding
@@ -20724,7 +20877,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@20.0.3(canvas@2.11.2(encoding@0.1.13)):
jsdom@20.0.3(canvas@2.11.2):
dependencies:
abab: 2.0.6
acorn: 8.15.0
@@ -20753,7 +20906,7 @@ snapshots:
ws: 8.19.0
xml-name-validator: 4.0.0
optionalDependencies:
canvas: 2.11.2(encoding@0.1.13)
canvas: 2.11.2
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -20790,6 +20943,36 @@ snapshots:
- utf-8-validate
optional: true
jsdom@26.1.0(canvas@2.11.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.19.0
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 2.11.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
optional: true
jsep@1.4.0: {}
jsesc@3.1.0: {}
@@ -21913,6 +22096,22 @@ snapshots:
ms@2.1.3: {}
msgpackr-extract@3.0.3:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
optional: true
msgpackr@1.11.5:
optionalDependencies:
msgpackr-extract: 3.0.3
multer@2.0.2:
dependencies:
append-field: 1.0.0
@@ -22031,6 +22230,11 @@ snapshots:
emojilib: 2.4.0
skin-tone: 2.0.0
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
node-fetch@2.7.0(encoding@0.1.13):
dependencies:
whatwg-url: 5.0.0
@@ -22039,6 +22243,11 @@ snapshots:
node-forge@1.3.3: {}
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-gyp-build@4.8.4: {}
node-gyp@12.2.0:
@@ -22078,8 +22287,6 @@ snapshots:
not@0.1.0: {}
notepack.io@3.0.1: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -23736,6 +23943,8 @@ snapshots:
semver@6.3.1: {}
semver@7.7.3: {}
semver@7.7.4: {}
send@0.19.2:
@@ -24826,8 +25035,6 @@ snapshots:
uglify-js@3.19.3:
optional: true
uid2@1.0.0: {}
uid@2.0.2:
dependencies:
'@lukeed/csprng': 1.1.0
@@ -25175,9 +25382,9 @@ snapshots:
optionalDependencies:
vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
@@ -25223,7 +25430,51 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-node: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.10.13
happy-dom: 20.5.0
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -25252,7 +25503,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 25.2.3
happy-dom: 20.5.0
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- jiti
- less

View File

@@ -35,6 +35,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
"@nestjs/platform-express": "^11.0.4",
@@ -55,12 +56,14 @@
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
"@socket.io/redis-adapter": "^8.3.0",
"@socket.io/postgres-adapter": "^0.5.0",
"@types/pg": "^8.16.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@@ -106,6 +109,7 @@
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.6",
"tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",

View File

@@ -5,8 +5,9 @@ import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { SocketIoAdapter } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
@@ -25,6 +26,7 @@ export async function configureExpress(
{
permitSwaggerWrite = true,
ssr,
socketIoAdapter,
}: {
/**
* Whether to allow swagger module to write to the specs.json
@@ -36,6 +38,10 @@ export async function configureExpress(
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
/**
* Override the Socket.IO adapter. If not specified, uses the adapter from config.
*/
socketIoAdapter?: SocketIoAdapter;
},
) {
const configRepository = app.get(ConfigRepository);
@@ -55,7 +61,7 @@ export async function configureExpress(
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(new WebSocketAdapter(app));
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });

View File

@@ -1,3 +1,4 @@
import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
@@ -21,7 +22,6 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -49,7 +49,7 @@ const commonMiddleware = [
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { cls, database, otel } = configRepository.getEnv();
const { bull, cls, database, otel } = configRepository.getEnv();
const commonImports = [
ClsModule.forRoot(cls.config),
@@ -57,6 +57,7 @@ const commonImports = [
OpenTelemetryModule.forRoot(otel),
];
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@@ -64,7 +65,6 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
logger: LoggingRepository,
private authService: AuthService,
private eventRepository: EventRepository,
private jobRepository: JobRepository,
private queueService: QueueService,
private telemetryRepository: TelemetryRepository,
private websocketRepository: WebsocketRepository,
@@ -91,13 +91,12 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
async onModuleDestroy() {
await this.eventRepository.emit('AppShutdown');
await this.jobRepository.onShutdown();
await teardownTelemetry();
}
}
@Module({
imports: [...commonImports, ScheduleModule.forRoot()],
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
@@ -138,13 +137,13 @@ export class MaintenanceModule {
}
@Module({
imports: [...commonImports],
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...commonImports],
imports: [...bullImports, ...commonImports],
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {

View File

@@ -10,6 +10,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { InternalController } from 'src/controllers/internal.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
@@ -51,6 +52,7 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
InternalController,
JobController,
LibraryController,
MaintenanceController,

View File

@@ -0,0 +1,22 @@
import { Body, Controller, NotFoundException, Post, Req } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { Request } from 'express';
import { AppRestartEvent, EventRepository } from 'src/repositories/event.repository';
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@ApiExcludeController()
@Controller('internal')
export class InternalController {
constructor(private eventRepository: EventRepository) {}
@Post('restart')
async restart(@Req() req: Request, @Body() dto: AppRestartEvent): Promise<void> {
const remoteAddress = req.socket.remoteAddress;
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
throw new NotFoundException();
}
await this.eventRepository.emit('AppRestart', dto);
}
}

View File

@@ -1,14 +1,14 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
describe('mapAsset', () => {
describe('peopleWithFaces', () => {
it('should transform all faces when a person has multiple faces in the same image', () => {
const person = PersonFactory.create();
const face1 = {
...faceStub.primaryFace1,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
@@ -18,8 +18,6 @@ describe('mapAsset', () => {
};
const face2 = {
...faceStub.primaryFace1,
id: 'assetFaceId-second',
boundingBoxX1: 300,
boundingBoxY1: 400,
boundingBoxX2: 400,
@@ -28,16 +26,22 @@ describe('mapAsset', () => {
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [face1, face2],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
};
const asset = AssetFactory.from()
.face(face1, (builder) => builder.person(person))
.face(face2, (builder) => builder.person(person))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.edit({
action: AssetEditAction.Crop,
parameters: {
width: 1512,
height: 1152,
x: 216,
y: 1512,
},
})
.build();
const result = mapAsset(asset as any);
const result = mapAsset(asset);
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
@@ -61,32 +65,22 @@ describe('mapAsset', () => {
});
it('should transform unassigned faces with edits and dimensions', () => {
const unassignedFace = {
...faceStub.noPerson1,
const unassignedFace = AssetFaceFactory.create({
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
};
});
const asset = {
...assetStub.withCropEdit,
faces: [unassignedFace],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
edits: [
{
action: AssetEditAction.Crop,
parameters: { x: 50, y: 50, width: 500, height: 400 },
},
],
};
const asset = AssetFactory.from()
.face(unassignedFace)
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
.build();
const result = mapAsset(asset as any);
const result = mapAsset(asset);
expect(result.unassignedFaces).toBeDefined();
expect(result.unassignedFaces).toHaveLength(1);
@@ -101,10 +95,6 @@ describe('mapAsset', () => {
it('should handle multiple people each with multiple faces', () => {
const person1Face1 = {
...faceStub.primaryFace1,
id: 'face-1-1',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
@@ -114,10 +104,6 @@ describe('mapAsset', () => {
};
const person1Face2 = {
...faceStub.primaryFace1,
id: 'face-1-2',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 400,
@@ -127,10 +113,6 @@ describe('mapAsset', () => {
};
const person2Face1 = {
...faceStub.mergeFace1,
id: 'face-2-1',
person: personStub.mergePerson,
personId: personStub.mergePerson.id,
boundingBoxX1: 500,
boundingBoxY1: 100,
boundingBoxX2: 600,
@@ -139,23 +121,22 @@ describe('mapAsset', () => {
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [person1Face1, person1Face2, person2Face1],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
edits: [],
};
const person = PersonFactory.create({ id: 'person-1' });
const result = mapAsset(asset as any);
const asset = AssetFactory.from()
.face(person1Face1, (builder) => builder.person(person))
.face(person1Face2, (builder) => builder.person(person))
.face(person2Face1, (builder) => builder.person({ id: 'person-2' }))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(asset);
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(2);
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
const person1 = result.people!.find((p) => p.id === 'person-1');
const person2 = result.people!.find((p) => p.id === 'person-2');
expect(person1).toBeDefined();
expect(person1!.faces).toHaveLength(2);
@@ -173,10 +154,6 @@ describe('mapAsset', () => {
it('should combine faces of the same person into a single entry', () => {
const face1 = {
...faceStub.primaryFace1,
id: 'face-1',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
@@ -186,10 +163,6 @@ describe('mapAsset', () => {
};
const face2 = {
...faceStub.primaryFace1,
id: 'face-2',
person: personStub.withName,
personId: personStub.withName.id,
boundingBoxX1: 300,
boundingBoxY1: 300,
boundingBoxX2: 400,
@@ -198,24 +171,21 @@ describe('mapAsset', () => {
imageHeight: 800,
};
const asset = {
...assetStub.withCropEdit,
faces: [face1, face2],
exifInfo: {
exifImageWidth: 1000,
exifImageHeight: 800,
},
edits: [],
};
const person = PersonFactory.create();
const result = mapAsset(asset as any);
const asset = AssetFactory.from()
.face(face1, (builder) => builder.person(person))
.face(face2, (builder) => builder.person(person))
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(asset);
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
const person = result.people![0];
expect(person.id).toBe(personStub.withName.id);
expect(person.faces).toHaveLength(2);
expect(result.people![0].id).toBe(person.id);
expect(result.people![0].faces).toHaveLength(2);
});
});
});

View File

@@ -1,6 +1,6 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel, SocketIoAdapter } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
export class EnvDto {
@@ -140,6 +140,11 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@IsEnum(SocketIoAdapter)
@Optional()
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -518,6 +518,11 @@ export enum ImmichTelemetry {
Job = 'job',
}
export enum SocketIoAdapter {
BroadcastChannel = 'broadcastchannel',
Postgres = 'postgres',
}
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
@@ -569,13 +574,6 @@ export enum QueueName {
Editor = 'editor',
}
export const JobQueueStatus = {
Pending: 0,
Active: 1,
Failed: 2,
} as const;
export type JobQueueStatus = (typeof JobQueueStatus)[keyof typeof JobQueueStatus];
export enum QueueJobStatus {
Active = 'active',
Failed = 'failed',
@@ -665,12 +663,6 @@ export enum JobName {
WorkflowRun = 'WorkflowRun',
}
type JobNameValue = (typeof JobName)[keyof typeof JobName];
const names = Object.values(JobName);
export const JobCode = Object.fromEntries(names.map((key, i) => [key, i])) as Record<JobNameValue, number>;
export const JOB_CODE_TO_NAME = Object.fromEntries(names.map((key, i) => [i, key])) as Record<number, JobNameValue>;
export type JobCode = (typeof JobCode)[keyof typeof JobCode];
export enum QueueCommand {
Start = 'start',
/** @deprecated Use `updateQueue` instead */

View File

@@ -1,6 +1,5 @@
import { Kysely, sql } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
@@ -18,7 +17,7 @@ class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
workers: Partial<Record<ImmichWorker, { kill: () => Promise<void> | void }>> = {};
/**
* Fail-safe in case anything dies during restart
@@ -101,25 +100,23 @@ class Workers {
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
const workerData: { inspectorPort?: number } = {};
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
if (inspectArg) {
const inspectorPorts: Record<ImmichWorker, number> = {
[ImmichWorker.Api]: 9230,
[ImmichWorker.Microservices]: 9231,
[ImmichWorker.Maintenance]: 9232,
};
workerData.inspectorPort = inspectorPorts[name];
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
const worker = new Worker(workerFile, { workerData });
const kill = async () => void (await worker.terminate());
worker.on('error', (error) => this.onError(name, error));
worker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
@@ -152,8 +149,8 @@ class Workers {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
console.error('Terminating api worker');
void this.workers[ImmichWorker.Api].kill();
}
}

View File

@@ -4,6 +4,7 @@ import {
Delete,
Get,
Next,
NotFoundException,
Param,
Post,
Req,
@@ -25,12 +26,15 @@ import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
import { FilenameParamDto } from 'src/validation';
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
@@ -131,4 +135,14 @@ export class MaintenanceWorkerController {
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
void this.service.setAction(dto);
}
@Post('internal/restart')
internalRestart(@Req() req: Request, @Body() dto: AppRestartEvent): void {
const remoteAddress = req.socket.remoteAddress;
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
throw new NotFoundException();
}
this.service.handleInternalRestart(dto);
}
}

View File

@@ -19,6 +19,7 @@ import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-webs
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -290,6 +291,9 @@ export class MaintenanceWorkerService {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
// Another maintenance worker has the lock - poll until maintenance mode ends
this.logger.log('Another worker has the maintenance lock, polling for maintenance mode changes...');
await this.pollForMaintenanceEnd();
return;
}
@@ -351,4 +355,25 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
handleInternalRestart(state: AppRestartEvent): void {
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
private async pollForMaintenanceEnd(): Promise<void> {
const pollIntervalMs = 5000;
while (true) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
if (!state?.isMaintenanceMode) {
this.logger.log('Maintenance mode ended, restarting...');
this.appRepository.exitApp();
return;
}
}
}
}

View File

@@ -0,0 +1,80 @@
import {
ClusterAdapterWithHeartbeat,
type ClusterAdapterOptions,
type ClusterMessage,
type ClusterResponse,
type ServerId,
} from 'socket.io-adapter';
const BC_CHANNEL_NAME = 'immich:socketio';
interface BroadcastChannelPayload {
type: 'message' | 'response';
sourceUid: string;
targetUid?: string;
data: unknown;
}
/**
* Socket.IO adapter using Node.js BroadcastChannel
*
* Relays messages between worker_threads within a single OS process.
* Zero external dependencies. Does NOT work across containers — use
* the Postgres adapter for multi-replica deployments.
*/
class BroadcastChannelAdapter extends ClusterAdapterWithHeartbeat {
private readonly channel: BroadcastChannel;
constructor(nsp: any, opts?: Partial<ClusterAdapterOptions>) {
super(nsp, opts ?? {});
this.channel = new BroadcastChannel(BC_CHANNEL_NAME);
this.channel.addEventListener('message', (event: MessageEvent<BroadcastChannelPayload>) => {
const msg = event.data;
if (msg.sourceUid === this.uid) {
return;
}
if (msg.type === 'message') {
this.onMessage(msg.data as ClusterMessage);
} else if (msg.type === 'response' && msg.targetUid === this.uid) {
this.onResponse(msg.data as ClusterResponse);
}
});
this.init();
}
override doPublish(message: ClusterMessage): Promise<string> {
this.channel.postMessage({
type: 'message',
sourceUid: this.uid,
data: message,
});
return Promise.resolve('');
}
override doPublishResponse(requesterUid: ServerId, response: ClusterResponse): Promise<void> {
this.channel.postMessage({
type: 'response',
sourceUid: this.uid,
targetUid: requesterUid,
data: response,
});
return Promise.resolve();
}
override close(): void {
super.close();
this.channel.close();
}
}
export function createBroadcastChannelAdapter(opts?: Partial<ClusterAdapterOptions>) {
const options: Partial<ClusterAdapterOptions> = {
...opts,
};
return function (nsp: any) {
return new BroadcastChannelAdapter(nsp, options);
};
}

View File

@@ -1,21 +1,103 @@
import { INestApplicationContext } from '@nestjs/common';
import { INestApplication, Logger } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { Pool, PoolConfig } from 'pg';
import type { ServerOptions } from 'socket.io';
import { SocketIoAdapter } from 'src/enum';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { asPostgresConnectionConfig } from 'src/utils/database';
export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
export function asPgPoolSsl(ssl?: Ssl): PoolConfig['ssl'] {
if (ssl === undefined || ssl === false || ssl === 'allow') {
return false;
}
if (ssl === true || ssl === 'prefer' || ssl === 'require') {
return { rejectUnauthorized: false };
}
if (ssl === 'verify-full') {
return { rejectUnauthorized: true };
}
return ssl;
}
class BroadcastChannelSocketAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createBroadcastChannelAdapter>;
constructor(app: INestApplication) {
super(app);
this.adapterConstructor = createBroadcastChannelAdapter();
}
createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get(ConfigRepository).getEnv();
const server = super.createIOServer(port, options);
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
server.adapter(this.adapterConstructor);
return server;
}
}
class PostgresSocketAdapter extends IoAdapter {
private adapterConstructor: any;
constructor(app: INestApplication, adapterConstructor: any) {
super(app);
this.adapterConstructor = adapterConstructor;
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
export async function createWebSocketAdapter(
app: INestApplication,
adapterOverride?: SocketIoAdapter,
): Promise<IoAdapter> {
const logger = new Logger('WebSocketAdapter');
const config = new ConfigRepository();
const { database, socketIo } = config.getEnv();
const adapter = adapterOverride ?? socketIo.adapter;
switch (adapter) {
case SocketIoAdapter.Postgres: {
logger.log('Using Postgres Socket.IO adapter');
const { createAdapter } = await import('@socket.io/postgres-adapter');
const config = asPostgresConnectionConfig(database.config);
const pool = new Pool({
host: config.host,
port: config.port,
user: config.username,
password: config.password,
database: config.database,
ssl: asPgPoolSsl(config.ssl),
max: 2,
});
await pool.query(`
CREATE TABLE IF NOT EXISTS socket_io_attachments (
id bigserial UNIQUE,
created_at timestamptz DEFAULT NOW(),
payload bytea
);
`);
pool.on('error', (error) => {
logger.error(' Postgres pool error', error);
});
const adapterConstructor = createAdapter(pool);
return new PostgresSocketAdapter(app, adapterConstructor);
}
case SocketIoAdapter.BroadcastChannel: {
logger.log('Using BroadcastChannel Socket.IO adapter');
return new BroadcastChannelSocketAdapter(app);
}
}
}

View File

@@ -1,7 +1,4 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@@ -24,24 +21,17 @@ export class AppRepository {
}
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
const { port } = new ConfigRepository().getEnv();
const url = `http://127.0.0.1:${port}/api/internal/restart`;
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
});
if (!response.ok) {
throw new Error(`Failed to trigger app restart: ${response.status} ${response.statusText}`);
}
}
}

View File

@@ -1,4 +1,6 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
@@ -17,6 +19,8 @@ import {
ImmichWorker,
LogFormat,
LogLevel,
QueueName,
SocketIoAdapter,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set';
@@ -45,6 +49,11 @@ export interface EnvData {
thirdPartySupportUrl?: string;
};
bull: {
config: QueueOptions;
queues: RegisterQueueOptions[];
};
cls: {
config: ClsModuleOptions;
};
@@ -108,6 +117,10 @@ export interface EnvData {
};
};
socketIo: {
adapter: SocketIoAdapter;
};
noColor: boolean;
nodeVersion?: string;
}
@@ -245,6 +258,19 @@ const getEnv = (): EnvData => {
thirdPartySupportUrl: dto.IMMICH_THIRD_PARTY_SUPPORT_URL,
},
bull: {
config: {
prefix: 'immich_bull',
connection: { ...redisConfig },
defaultJobOptions: {
attempts: 1,
removeOnComplete: true,
removeOnFail: false,
},
},
queues: Object.values(QueueName).map((name) => ({ name })),
},
cls: {
config: {
middleware: {
@@ -325,6 +351,10 @@ const getEnv = (): EnvData => {
},
},
socketIo: {
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
},
noColor: !!dto.NO_COLOR,
};
};

View File

@@ -1,30 +1,16 @@
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { JobsOptions, Queue, Worker } from 'bullmq';
import { ClassConstructor } from 'class-transformer';
import { Kysely, sql } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { setTimeout } from 'node:timers/promises';
import postgres from 'postgres';
import { JobConfig } from 'src/decorators';
import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto';
import {
JOB_CODE_TO_NAME,
JobCode,
JobName,
JobQueueStatus,
JobStatus,
MetadataKey,
QueueCleanType,
QueueJobStatus,
QueueName,
} from 'src/enum';
import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { ConcurrentQueueName, JobCounts, JobItem, JobOf } from 'src/types';
import { asPostgresConnectionConfig } from 'src/utils/database';
import { getTable, InsertRow, QueueWorker, WriteBuffer } from 'src/utils/job-queue.util';
import { JobCounts, JobItem, JobOf } from 'src/types';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
type JobMapItem = {
@@ -34,54 +20,10 @@ type JobMapItem = {
label: string;
};
const SERIAL_QUEUES = [
QueueName.FacialRecognition,
QueueName.StorageTemplateMigration,
QueueName.DuplicateDetection,
QueueName.BackupDatabase,
];
export const isConcurrentQueue = (name: QueueName): name is ConcurrentQueueName => !SERIAL_QUEUES.includes(name);
const getClaimBatch = (queueName: QueueName): number => {
if (SERIAL_QUEUES.includes(queueName)) {
return 1;
}
switch (queueName) {
case QueueName.VideoConversion: {
return 1;
}
case QueueName.FaceDetection:
case QueueName.SmartSearch:
case QueueName.Ocr: {
return 2;
}
default: {
return 100; // will be clamped to slotsAvailable by the worker
}
}
};
const STATUS_FILTER = {
[QueueJobStatus.Active]: JobQueueStatus.Active,
[QueueJobStatus.Failed]: null as null, // failures are in a separate table
[QueueJobStatus.Waiting]: JobQueueStatus.Pending,
[QueueJobStatus.Complete]: null as null, // completed jobs are deleted
[QueueJobStatus.Delayed]: JobQueueStatus.Pending, // delayed = pending with future run_after
[QueueJobStatus.Paused]: JobQueueStatus.Pending, // paused queue has pending jobs
};
@Injectable()
export class JobRepository {
private workers: Partial<Record<QueueName, QueueWorker>> = {};
private workers: Partial<Record<QueueName, Worker>> = {};
private handlers: Partial<Record<JobName, JobMapItem>> = {};
private writeBuffer!: WriteBuffer;
private pool: postgres.Sql | null = null;
private db!: Kysely<DB>;
private listenConn: postgres.Sql | null = null;
private listenReady = false;
private pauseState: Partial<Record<QueueName, boolean>> = {};
constructor(
private moduleRef: ModuleRef,
@@ -140,46 +82,21 @@ export class JobRepository {
throw new ImmichStartupError(errorMessage);
}
}
this.pool = this.createPgConnection({ max: 20, connection: { synchronous_commit: 'off' } });
this.db = new Kysely<DB>({ dialect: new PostgresJSDialect({ postgres: this.pool }) });
this.writeBuffer = new WriteBuffer(
this.pool,
(queue) => this.notify(queue),
(error) => this.logger.error(`Failed to flush job write buffer: ${error}`),
);
}
async startWorkers() {
// Startup sweep: reset any active jobs from a previous crash
await Promise.all(
Object.values(QueueName).map((queueName) =>
this.db
.updateTable(getTable(this.db, queueName))
.set({ status: JobQueueStatus.Pending, startedAt: null, expiresAt: null })
.where('status', '=', JobQueueStatus.Active)
.where('expiresAt', '<', sql<Date>`now()`) // needed for multi-instance safety
.execute(),
),
);
startWorkers() {
const { bull } = this.configRepository.getEnv();
for (const queueName of Object.values(QueueName)) {
this.workers[queueName] = new QueueWorker({
this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(
queueName,
stallTimeout: 5 * 60 * 1000, // 5 min
claimBatch: getClaimBatch(queueName),
maxRetries: 5,
backoffBaseMs: 30_000,
concurrency: 1,
db: this.db,
onJob: (job) => this.eventRepository.emit('JobRun', queueName, job),
});
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}
await this.setupListen();
}
run({ name, data }: JobItem) {
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${name}"`);
@@ -196,127 +113,84 @@ export class JobRepository {
return;
}
worker.setConcurrency(concurrency);
worker.concurrency = concurrency;
}
async isActive(name: QueueName): Promise<boolean> {
const result = await this.db
.selectFrom(getTable(this.db, name))
.select('id')
.where('status', '=', JobQueueStatus.Active)
.limit(1)
.executeTakeFirst();
return result !== undefined;
const queue = this.getQueue(name);
const count = await queue.getActiveCount();
return count > 0;
}
isPaused(name: QueueName): Promise<boolean> {
return Promise.resolve(this.pauseState[name] ?? false);
async isPaused(name: QueueName): Promise<boolean> {
return this.getQueue(name).isPaused();
}
async pause(name: QueueName) {
this.pauseState[name] = true;
await this.db
.insertInto('job_queue_meta')
.values({ queueName: name, isPaused: true })
.onConflict((oc) => oc.column('queueName').doUpdateSet({ isPaused: true }))
.execute();
this.workers[name]?.pause();
await this.notify(name, 'pause');
pause(name: QueueName) {
return this.getQueue(name).pause();
}
async resume(name: QueueName) {
this.pauseState[name] = false;
await this.db
.insertInto('job_queue_meta')
.values({ queueName: name, isPaused: false })
.onConflict((oc) => oc.column('queueName').doUpdateSet({ isPaused: false }))
.execute();
this.workers[name]?.resume();
await this.notify(name, 'resume');
resume(name: QueueName) {
return this.getQueue(name).resume();
}
empty(name: QueueName) {
return this.db.deleteFrom(getTable(this.db, name)).where('status', '=', JobQueueStatus.Pending).execute();
return this.getQueue(name).drain();
}
clear(name: QueueName, _type: QueueCleanType) {
return this.db.deleteFrom('job_failures').where('queueName', '=', name).execute();
clear(name: QueueName, type: QueueCleanType) {
return this.getQueue(name).clean(0, 1000, type);
}
async getJobCounts(name: QueueName): Promise<JobCounts> {
const [statusResult, failedResult] = await Promise.all([
this.db
.selectFrom(getTable(this.db, name))
.select((eb) => ['status', eb.fn.countAll<number>().as('count')])
.groupBy('status')
.execute(),
this.db
.selectFrom('job_failures')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('queueName', '=', name)
.executeTakeFirst(),
]);
const counts: JobCounts = {
active: 0,
completed: 0,
failed: Number(failedResult?.count ?? 0),
delayed: 0,
waiting: 0,
paused: 0,
};
for (const row of statusResult) {
switch (row.status) {
case JobQueueStatus.Pending: {
counts.waiting = Number(row.count);
break;
}
case JobQueueStatus.Active: {
counts.active = Number(row.count);
break;
}
}
}
if (this.pauseState[name]) {
counts.paused = counts.waiting;
counts.waiting = 0;
}
return counts;
getJobCounts(name: QueueName): Promise<JobCounts> {
return this.getQueue(name).getJobCounts(
'active',
'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
}
private getQueueName(name: JobName) {
return (this.handlers[name] as JobMapItem).queueName;
}
queueAll(items: JobItem[]): void {
async queueAll(items: JobItem[]): Promise<void> {
if (items.length === 0) {
return;
}
const bufferItems: { queue: QueueName; row: InsertRow }[] = [];
const promises = [];
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
for (const item of items) {
const queueName = this.getQueueName(item.name);
const options = this.getJobOptions(item);
bufferItems.push({
queue: queueName,
row: {
code: JobCode[item.name],
data: item.data ?? null,
priority: options?.priority ?? null,
dedupKey: options?.dedupKey ?? null,
runAfter: options?.delay ? new Date(Date.now() + options.delay) : null,
},
});
const job = {
name: item.name,
data: item.data || {},
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
itemsByQueue[queueName].push(job);
}
}
this.writeBuffer.add(bufferItems);
for (const [queueName, jobs] of Object.entries(itemsByQueue)) {
const queue = this.getQueue(queueName as QueueName);
promises.push(queue.addBulk(jobs));
}
await Promise.all(promises);
}
queue(item: JobItem): void {
this.queueAll([item]);
async queue(item: JobItem): Promise<void> {
return this.queueAll([item]);
}
async waitForQueueCompletion(...queues: QueueName[]): Promise<void> {
@@ -335,76 +209,29 @@ export class JobRepository {
}
async searchJobs(name: QueueName, dto: QueueJobSearchDto): Promise<QueueJobResponseDto[]> {
const requestedStatuses = dto.status ?? Object.values(QueueJobStatus);
const includeFailed = requestedStatuses.includes(QueueJobStatus.Failed);
const statuses: JobQueueStatus[] = [];
for (const status of requestedStatuses) {
const mapped = STATUS_FILTER[status];
if (mapped !== null && !statuses.includes(mapped)) {
statuses.push(mapped);
}
}
const results: QueueJobResponseDto[] = [];
if (statuses.length > 0) {
const rows = await this.db
.selectFrom(getTable(this.db, name))
.select(['id', 'code', 'data', 'runAfter'])
.where('status', 'in', statuses)
.orderBy('id', 'desc')
.limit(1000)
.execute();
for (const row of rows) {
results.push({
id: String(row.id),
name: JOB_CODE_TO_NAME[row.code],
data: (row.data ?? {}) as object,
timestamp: new Date(row.runAfter).getTime(),
});
}
}
if (includeFailed) {
const failedRows = await this.db
.selectFrom('job_failures')
.select(['id', 'code', 'data', 'failedAt'])
.where('queueName', '=', name)
.orderBy('id', 'desc')
.limit(1000)
.execute();
for (const row of failedRows) {
results.push({
id: `f-${row.id}`,
name: JOB_CODE_TO_NAME[row.code],
data: (row.data ?? {}) as object,
timestamp: new Date(row.failedAt).getTime(),
});
}
}
return results;
const jobs = await this.getQueue(name).getJobs(dto.status ?? Object.values(QueueJobStatus), 0, 1000);
return jobs.map((job) => {
const { id, name, timestamp, data } = job;
return { id, name: name as JobName, timestamp, data };
});
}
private getJobOptions(item: JobItem): { dedupKey?: string; priority?: number; delay?: number } | null {
private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.NotifyAlbumUpdate: {
return {
dedupKey: `${item.data.id}/${item.data.recipientId}`,
jobId: `${item.data.id}/${item.data.recipientId}`,
delay: item.data?.delay,
};
}
case JobName.StorageTemplateMigrationSingle: {
return { dedupKey: item.data.id };
return { jobId: item.data.id };
}
case JobName.PersonGenerateThumbnail: {
return { priority: 1 };
}
case JobName.FacialRecognitionQueueAll: {
return { dedupKey: JobName.FacialRecognitionQueueAll };
return { jobId: JobName.FacialRecognitionQueueAll };
}
default: {
return null;
@@ -412,110 +239,16 @@ export class JobRepository {
}
}
private createPgConnection(options?: { max?: number; connection?: Record<string, string> }) {
const { database } = this.configRepository.getEnv();
const pgConfig = asPostgresConnectionConfig(database.config);
return postgres({
host: pgConfig.host,
port: pgConfig.port,
username: pgConfig.username,
password: pgConfig.password as string | undefined,
database: pgConfig.database,
ssl: pgConfig.ssl as boolean | undefined,
max: options?.max ?? 1,
connection: options?.connection,
});
private getQueue(queue: QueueName): Queue {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
}
private async setupListen(): Promise<void> {
if (this.listenConn) {
await this.listenConn.end();
this.listenConn = null;
}
this.listenConn = this.createPgConnection();
for (const queueName of Object.values(QueueName)) {
await this.listenConn.listen(
`jobs:${queueName}`,
(payload) => this.onNotify(queueName, payload),
() => this.onReconnect(),
);
}
this.listenReady = true;
await this.syncPauseState();
for (const worker of Object.values(this.workers)) {
worker.onNotification();
}
}
private onNotify(queueName: QueueName, payload: string) {
switch (payload) {
case 'pause': {
this.pauseState[queueName] = true;
this.workers[queueName]?.pause();
break;
}
case 'resume': {
this.pauseState[queueName] = false;
this.workers[queueName]?.resume();
break;
}
default: {
this.workers[queueName]?.onNotification();
break;
}
}
}
private onReconnect() {
if (!this.listenReady) {
return;
}
this.listenReady = false;
this.logger.log('LISTEN connection re-established, syncing state');
void this.syncPauseState().then(() => {
for (const worker of Object.values(this.workers)) {
worker.onNotification();
}
this.listenReady = true;
});
}
private async syncPauseState(): Promise<void> {
const metaRows = await this.db.selectFrom('job_queue_meta').selectAll().execute();
for (const row of metaRows) {
const queueName = row.queueName as QueueName;
const wasPaused = this.pauseState[queueName] ?? false;
this.pauseState[queueName] = row.isPaused;
if (wasPaused && !row.isPaused) {
this.workers[queueName]?.resume();
} else if (!wasPaused && row.isPaused) {
this.workers[queueName]?.pause();
}
}
}
private notify(queue: QueueName, payload = '') {
return sql`SELECT pg_notify(${`jobs:${queue}`}, ${payload})`.execute(this.db);
}
async onShutdown(): Promise<void> {
const shutdownPromises = Object.values(this.workers).map((worker) => worker.shutdown());
await Promise.all(shutdownPromises);
if (this.writeBuffer) {
await this.writeBuffer.flush();
}
if (this.pool) {
await this.pool.end();
this.pool = null;
}
if (this.listenConn) {
await this.listenConn.end();
this.listenConn = null;
/** @deprecated */
// todo: remove this when asset notifications no longer need it.
public async removeJob(name: JobName, jobID: string): Promise<void> {
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID);
if (existingJob) {
await existingJob.remove();
}
}
}

View File

@@ -41,28 +41,6 @@ import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import {
JobFailuresTable,
JobQueueMetaTable,
JobsBackgroundTaskTable,
JobsBackupDatabaseTable,
JobsDuplicateDetectionTable,
JobsEditorTable,
JobsFaceDetectionTable,
JobsFacialRecognitionTable,
JobsLibraryTable,
JobsMetadataExtractionTable,
JobsMigrationTable,
JobsNotificationTable,
JobsOcrTable,
JobsSearchTable,
JobsSidecarTable,
JobsSmartSearchTable,
JobsStorageTemplateMigrationTable,
JobsThumbnailGenerationTable,
JobsVideoConversionTable,
JobsWorkflowTable,
} from 'src/schema/tables/job.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
@@ -157,26 +135,6 @@ export class ImmichDatabase {
WorkflowTable,
WorkflowFilterTable,
WorkflowActionTable,
JobsThumbnailGenerationTable,
JobsMetadataExtractionTable,
JobsVideoConversionTable,
JobsFaceDetectionTable,
JobsFacialRecognitionTable,
JobsSmartSearchTable,
JobsDuplicateDetectionTable,
JobsBackgroundTaskTable,
JobsStorageTemplateMigrationTable,
JobsMigrationTable,
JobsSearchTable,
JobsSidecarTable,
JobsLibraryTable,
JobsNotificationTable,
JobsBackupDatabaseTable,
JobsOcrTable,
JobsWorkflowTable,
JobsEditorTable,
JobQueueMetaTable,
JobFailuresTable,
];
functions = [
@@ -294,25 +252,4 @@ export interface DB {
workflow: WorkflowTable;
workflow_filter: WorkflowFilterTable;
workflow_action: WorkflowActionTable;
jobs_thumbnail_generation: JobsThumbnailGenerationTable;
jobs_metadata_extraction: JobsMetadataExtractionTable;
jobs_video_conversion: JobsVideoConversionTable;
jobs_face_detection: JobsFaceDetectionTable;
jobs_facial_recognition: JobsFacialRecognitionTable;
jobs_smart_search: JobsSmartSearchTable;
jobs_duplicate_detection: JobsDuplicateDetectionTable;
jobs_background_task: JobsBackgroundTaskTable;
jobs_storage_template_migration: JobsStorageTemplateMigrationTable;
jobs_migration: JobsMigrationTable;
jobs_search: JobsSearchTable;
jobs_sidecar: JobsSidecarTable;
jobs_library: JobsLibraryTable;
jobs_notification: JobsNotificationTable;
jobs_backup_database: JobsBackupDatabaseTable;
jobs_ocr: JobsOcrTable;
jobs_workflow: JobsWorkflowTable;
jobs_editor: JobsEditorTable;
job_queue_meta: JobQueueMetaTable;
job_failures: JobFailuresTable;
}

View File

@@ -1,492 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "jobs_thumbnail_generation" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_thumbnail_generation_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_thumbnail_generation_dedup" ON "jobs_thumbnail_generation" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_thumbnail_generation_pending" ON "jobs_thumbnail_generation" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_metadata_extraction" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_metadata_extraction_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_metadata_extraction_dedup" ON "jobs_metadata_extraction" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_metadata_extraction_pending" ON "jobs_metadata_extraction" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_video_conversion" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_video_conversion_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_video_conversion_dedup" ON "jobs_video_conversion" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_video_conversion_pending" ON "jobs_video_conversion" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_face_detection" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_face_detection_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_face_detection_dedup" ON "jobs_face_detection" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_face_detection_pending" ON "jobs_face_detection" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_facial_recognition" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_facial_recognition_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_facial_recognition_dedup" ON "jobs_facial_recognition" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_facial_recognition_pending" ON "jobs_facial_recognition" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_smart_search" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_smart_search_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_smart_search_dedup" ON "jobs_smart_search" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_smart_search_pending" ON "jobs_smart_search" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_duplicate_detection" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_duplicate_detection_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_duplicate_detection_dedup" ON "jobs_duplicate_detection" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_duplicate_detection_pending" ON "jobs_duplicate_detection" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_background_task" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_background_task_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_background_task_dedup" ON "jobs_background_task" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_background_task_pending" ON "jobs_background_task" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_storage_template_migration" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_storage_template_migration_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_storage_template_migration_dedup" ON "jobs_storage_template_migration" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_storage_template_migration_pending" ON "jobs_storage_template_migration" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_migration" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_migration_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_migration_dedup" ON "jobs_migration" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_migration_pending" ON "jobs_migration" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_search" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_search_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_search_dedup" ON "jobs_search" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_search_pending" ON "jobs_search" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_sidecar" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_sidecar_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_sidecar_dedup" ON "jobs_sidecar" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_sidecar_pending" ON "jobs_sidecar" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_library" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_library_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_library_dedup" ON "jobs_library" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_library_pending" ON "jobs_library" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_notification" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_notification_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_notification_dedup" ON "jobs_notification" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_notification_pending" ON "jobs_notification" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_backup_database" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_backup_database_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_backup_database_dedup" ON "jobs_backup_database" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_backup_database_pending" ON "jobs_backup_database" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_ocr" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_ocr_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_ocr_dedup" ON "jobs_ocr" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_ocr_pending" ON "jobs_ocr" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_workflow" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_workflow_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_workflow_dedup" ON "jobs_workflow" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_workflow_pending" ON "jobs_workflow" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "jobs_editor" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"runAfter" timestamp with time zone NOT NULL DEFAULT now(),
"startedAt" timestamp with time zone,
"expiresAt" timestamp with time zone,
"code" smallint NOT NULL,
"priority" smallint NOT NULL DEFAULT 0,
"status" smallint NOT NULL DEFAULT 0,
"retries" smallint NOT NULL DEFAULT 0,
"data" jsonb,
"dedupKey" text,
CONSTRAINT "jobs_editor_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "IDX_jobs_editor_dedup" ON "jobs_editor" ("dedupKey") WHERE "dedupKey" IS NOT NULL;`.execute(db);
await sql`CREATE INDEX "IDX_jobs_editor_pending" ON "jobs_editor" (priority DESC, id ASC);`.execute(db);
await sql`CREATE TABLE "job_queue_meta" (
"queueName" text NOT NULL,
"isPaused" boolean NOT NULL DEFAULT false,
CONSTRAINT "job_queue_meta_pkey" PRIMARY KEY ("queueName")
);`.execute(db);
await sql`CREATE TABLE "job_failures" (
"id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY,
"failedAt" timestamp with time zone NOT NULL DEFAULT now(),
"queueName" text NOT NULL,
"code" smallint NOT NULL,
"data" jsonb,
"error" text,
CONSTRAINT "job_failures_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "IDX_job_failures_queue" ON "job_failures" ("queueName");`.execute(db);
await sql`ALTER TABLE "jobs_thumbnail_generation" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_thumbnail_generation" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_thumbnail_generation" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_migration" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_migration" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_migration" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_search" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_search" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_search" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_library" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_library" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_library" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_notification" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_notification" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_notification" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`ALTER TABLE "jobs_editor" SET (autovacuum_vacuum_cost_delay = 0)`.execute(db);
await sql`ALTER TABLE "jobs_editor" SET (autovacuum_vacuum_scale_factor = 0.01)`.execute(db);
await sql`ALTER TABLE "jobs_editor" SET (autovacuum_vacuum_threshold = 100)`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_thumbnail_generation_dedup', '{"type":"index","name":"IDX_jobs_thumbnail_generation_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_thumbnail_generation_dedup\\" ON \\"jobs_thumbnail_generation\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_thumbnail_generation_pending', '{"type":"index","name":"IDX_jobs_thumbnail_generation_pending","sql":"CREATE INDEX \\"IDX_jobs_thumbnail_generation_pending\\" ON \\"jobs_thumbnail_generation\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_metadata_extraction_dedup', '{"type":"index","name":"IDX_jobs_metadata_extraction_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_metadata_extraction_dedup\\" ON \\"jobs_metadata_extraction\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_metadata_extraction_pending', '{"type":"index","name":"IDX_jobs_metadata_extraction_pending","sql":"CREATE INDEX \\"IDX_jobs_metadata_extraction_pending\\" ON \\"jobs_metadata_extraction\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_video_conversion_dedup', '{"type":"index","name":"IDX_jobs_video_conversion_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_video_conversion_dedup\\" ON \\"jobs_video_conversion\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_video_conversion_pending', '{"type":"index","name":"IDX_jobs_video_conversion_pending","sql":"CREATE INDEX \\"IDX_jobs_video_conversion_pending\\" ON \\"jobs_video_conversion\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_face_detection_dedup', '{"type":"index","name":"IDX_jobs_face_detection_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_face_detection_dedup\\" ON \\"jobs_face_detection\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_face_detection_pending', '{"type":"index","name":"IDX_jobs_face_detection_pending","sql":"CREATE INDEX \\"IDX_jobs_face_detection_pending\\" ON \\"jobs_face_detection\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_facial_recognition_dedup', '{"type":"index","name":"IDX_jobs_facial_recognition_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_facial_recognition_dedup\\" ON \\"jobs_facial_recognition\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_facial_recognition_pending', '{"type":"index","name":"IDX_jobs_facial_recognition_pending","sql":"CREATE INDEX \\"IDX_jobs_facial_recognition_pending\\" ON \\"jobs_facial_recognition\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_smart_search_dedup', '{"type":"index","name":"IDX_jobs_smart_search_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_smart_search_dedup\\" ON \\"jobs_smart_search\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_smart_search_pending', '{"type":"index","name":"IDX_jobs_smart_search_pending","sql":"CREATE INDEX \\"IDX_jobs_smart_search_pending\\" ON \\"jobs_smart_search\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_duplicate_detection_dedup', '{"type":"index","name":"IDX_jobs_duplicate_detection_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_duplicate_detection_dedup\\" ON \\"jobs_duplicate_detection\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_duplicate_detection_pending', '{"type":"index","name":"IDX_jobs_duplicate_detection_pending","sql":"CREATE INDEX \\"IDX_jobs_duplicate_detection_pending\\" ON \\"jobs_duplicate_detection\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_background_task_dedup', '{"type":"index","name":"IDX_jobs_background_task_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_background_task_dedup\\" ON \\"jobs_background_task\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_background_task_pending', '{"type":"index","name":"IDX_jobs_background_task_pending","sql":"CREATE INDEX \\"IDX_jobs_background_task_pending\\" ON \\"jobs_background_task\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_storage_template_migration_dedup', '{"type":"index","name":"IDX_jobs_storage_template_migration_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_storage_template_migration_dedup\\" ON \\"jobs_storage_template_migration\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_storage_template_migration_pending', '{"type":"index","name":"IDX_jobs_storage_template_migration_pending","sql":"CREATE INDEX \\"IDX_jobs_storage_template_migration_pending\\" ON \\"jobs_storage_template_migration\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_migration_dedup', '{"type":"index","name":"IDX_jobs_migration_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_migration_dedup\\" ON \\"jobs_migration\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_migration_pending', '{"type":"index","name":"IDX_jobs_migration_pending","sql":"CREATE INDEX \\"IDX_jobs_migration_pending\\" ON \\"jobs_migration\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_search_dedup', '{"type":"index","name":"IDX_jobs_search_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_search_dedup\\" ON \\"jobs_search\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_search_pending', '{"type":"index","name":"IDX_jobs_search_pending","sql":"CREATE INDEX \\"IDX_jobs_search_pending\\" ON \\"jobs_search\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_sidecar_dedup', '{"type":"index","name":"IDX_jobs_sidecar_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_sidecar_dedup\\" ON \\"jobs_sidecar\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_sidecar_pending', '{"type":"index","name":"IDX_jobs_sidecar_pending","sql":"CREATE INDEX \\"IDX_jobs_sidecar_pending\\" ON \\"jobs_sidecar\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_library_dedup', '{"type":"index","name":"IDX_jobs_library_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_library_dedup\\" ON \\"jobs_library\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_library_pending', '{"type":"index","name":"IDX_jobs_library_pending","sql":"CREATE INDEX \\"IDX_jobs_library_pending\\" ON \\"jobs_library\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_notification_dedup', '{"type":"index","name":"IDX_jobs_notification_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_notification_dedup\\" ON \\"jobs_notification\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_notification_pending', '{"type":"index","name":"IDX_jobs_notification_pending","sql":"CREATE INDEX \\"IDX_jobs_notification_pending\\" ON \\"jobs_notification\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_backup_database_dedup', '{"type":"index","name":"IDX_jobs_backup_database_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_backup_database_dedup\\" ON \\"jobs_backup_database\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_backup_database_pending', '{"type":"index","name":"IDX_jobs_backup_database_pending","sql":"CREATE INDEX \\"IDX_jobs_backup_database_pending\\" ON \\"jobs_backup_database\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_ocr_dedup', '{"type":"index","name":"IDX_jobs_ocr_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_ocr_dedup\\" ON \\"jobs_ocr\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_ocr_pending', '{"type":"index","name":"IDX_jobs_ocr_pending","sql":"CREATE INDEX \\"IDX_jobs_ocr_pending\\" ON \\"jobs_ocr\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_workflow_dedup', '{"type":"index","name":"IDX_jobs_workflow_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_workflow_dedup\\" ON \\"jobs_workflow\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_workflow_pending', '{"type":"index","name":"IDX_jobs_workflow_pending","sql":"CREATE INDEX \\"IDX_jobs_workflow_pending\\" ON \\"jobs_workflow\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_editor_dedup', '{"type":"index","name":"IDX_jobs_editor_dedup","sql":"CREATE UNIQUE INDEX \\"IDX_jobs_editor_dedup\\" ON \\"jobs_editor\\" (\\"dedupKey\\") WHERE \\"dedupKey\\" IS NOT NULL;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_jobs_editor_pending', '{"type":"index","name":"IDX_jobs_editor_pending","sql":"CREATE INDEX \\"IDX_jobs_editor_pending\\" ON \\"jobs_editor\\" (priority DESC, id ASC);"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "jobs_thumbnail_generation" RESET (jobs_thumbnail_generation.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_thumbnail_generation" RESET (jobs_thumbnail_generation.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_thumbnail_generation" RESET (jobs_thumbnail_generation.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" RESET (jobs_metadata_extraction.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" RESET (jobs_metadata_extraction.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_metadata_extraction" RESET (jobs_metadata_extraction.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" RESET (jobs_video_conversion.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" RESET (jobs_video_conversion.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_video_conversion" RESET (jobs_video_conversion.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" RESET (jobs_face_detection.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" RESET (jobs_face_detection.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_face_detection" RESET (jobs_face_detection.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" RESET (jobs_facial_recognition.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" RESET (jobs_facial_recognition.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_facial_recognition" RESET (jobs_facial_recognition.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" RESET (jobs_smart_search.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" RESET (jobs_smart_search.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_smart_search" RESET (jobs_smart_search.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" RESET (jobs_duplicate_detection.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" RESET (jobs_duplicate_detection.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_duplicate_detection" RESET (jobs_duplicate_detection.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" RESET (jobs_background_task.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" RESET (jobs_background_task.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_background_task" RESET (jobs_background_task.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" RESET (jobs_storage_template_migration.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" RESET (jobs_storage_template_migration.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_storage_template_migration" RESET (jobs_storage_template_migration.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_migration" RESET (jobs_migration.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_migration" RESET (jobs_migration.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_migration" RESET (jobs_migration.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_search" RESET (jobs_search.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_search" RESET (jobs_search.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_search" RESET (jobs_search.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" RESET (jobs_sidecar.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" RESET (jobs_sidecar.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_sidecar" RESET (jobs_sidecar.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_library" RESET (jobs_library.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_library" RESET (jobs_library.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_library" RESET (jobs_library.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_notification" RESET (jobs_notification.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_notification" RESET (jobs_notification.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_notification" RESET (jobs_notification.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" RESET (jobs_backup_database.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" RESET (jobs_backup_database.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_backup_database" RESET (jobs_backup_database.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" RESET (jobs_ocr.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" RESET (jobs_ocr.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_ocr" RESET (jobs_ocr.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" RESET (jobs_workflow.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" RESET (jobs_workflow.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_workflow" RESET (jobs_workflow.autovacuum_vacuum_threshold)`.execute(db);
await sql`ALTER TABLE "jobs_editor" RESET (jobs_editor.autovacuum_vacuum_cost_delay)`.execute(db);
await sql`ALTER TABLE "jobs_editor" RESET (jobs_editor.autovacuum_vacuum_scale_factor)`.execute(db);
await sql`ALTER TABLE "jobs_editor" RESET (jobs_editor.autovacuum_vacuum_threshold)`.execute(db);
await sql`DROP TABLE "jobs_thumbnail_generation";`.execute(db);
await sql`DROP TABLE "jobs_metadata_extraction";`.execute(db);
await sql`DROP TABLE "jobs_video_conversion";`.execute(db);
await sql`DROP TABLE "jobs_face_detection";`.execute(db);
await sql`DROP TABLE "jobs_facial_recognition";`.execute(db);
await sql`DROP TABLE "jobs_smart_search";`.execute(db);
await sql`DROP TABLE "jobs_duplicate_detection";`.execute(db);
await sql`DROP TABLE "jobs_background_task";`.execute(db);
await sql`DROP TABLE "jobs_storage_template_migration";`.execute(db);
await sql`DROP TABLE "jobs_migration";`.execute(db);
await sql`DROP TABLE "jobs_search";`.execute(db);
await sql`DROP TABLE "jobs_sidecar";`.execute(db);
await sql`DROP TABLE "jobs_library";`.execute(db);
await sql`DROP TABLE "jobs_notification";`.execute(db);
await sql`DROP TABLE "jobs_backup_database";`.execute(db);
await sql`DROP TABLE "jobs_ocr";`.execute(db);
await sql`DROP TABLE "jobs_workflow";`.execute(db);
await sql`DROP TABLE "jobs_editor";`.execute(db);
await sql`DROP TABLE "job_queue_meta";`.execute(db);
await sql`DROP TABLE "job_failures";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_thumbnail_generation_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_thumbnail_generation_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_metadata_extraction_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_metadata_extraction_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_video_conversion_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_video_conversion_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_face_detection_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_face_detection_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_facial_recognition_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_facial_recognition_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_smart_search_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_smart_search_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_duplicate_detection_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_duplicate_detection_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_background_task_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_background_task_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_storage_template_migration_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_storage_template_migration_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_migration_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_migration_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_search_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_search_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_sidecar_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_sidecar_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_library_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_library_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_notification_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_notification_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_backup_database_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_backup_database_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_ocr_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_ocr_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_workflow_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_workflow_pending';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_editor_dedup';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_jobs_editor_pending';`.execute(db);
}

View File

@@ -1,145 +0,0 @@
import { JobCode, JobQueueStatus, QueueName } from 'src/enum';
import { Column, ConfigurationParameter, Generated, Index, PrimaryColumn, Table } from 'src/sql-tools';
export type JobTable = {
id: Generated<number>;
runAfter: Generated<Date>;
startedAt: Date | null;
expiresAt: Date | null;
code: JobCode;
priority: Generated<number>;
status: Generated<JobQueueStatus>;
retries: Generated<number>;
data: unknown;
dedupKey: string | null;
};
export type JobFailureTable = {
id: Generated<number>;
failedAt: Generated<Date>;
queueName: string;
code: JobCode;
data: unknown;
error: string | null;
};
function defineJobTable(name: string) {
class JobTable {
@PrimaryColumn({ type: 'bigint', identity: true })
id!: Generated<number>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
runAfter!: Generated<Date>;
@Column({ type: 'timestamp with time zone', nullable: true })
startedAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
expiresAt!: Date | null;
@Column({ type: 'smallint' })
code!: JobCode;
@Column({ type: 'smallint', default: 0 })
priority!: Generated<number>;
@Column({ type: 'smallint', default: 0 })
status!: Generated<JobQueueStatus>;
@Column({ type: 'smallint', default: 0 })
retries!: Generated<number>;
@Column({ type: 'jsonb', nullable: true })
data!: unknown;
@Column({ type: 'text', nullable: true })
dedupKey!: string | null;
}
const decorated = [
ConfigurationParameter({ name: 'autovacuum_vacuum_cost_delay', value: 0, scope: 'table' }),
ConfigurationParameter({ name: 'autovacuum_vacuum_scale_factor', value: 0.01, scope: 'table' }),
ConfigurationParameter({ name: 'autovacuum_vacuum_threshold', value: 100, scope: 'table' }),
Index({
name: `IDX_${name}_dedup`,
columns: ['dedupKey'],
unique: true,
where: `"dedupKey" IS NOT NULL`,
}),
Index({ name: `IDX_${name}_pending`, expression: 'priority DESC, id ASC' }),
Table(name),
].reduce((cls, dec) => dec(cls) || cls, JobTable);
Object.defineProperty(decorated, 'name', { value: name });
return decorated;
}
export const JobsThumbnailGenerationTable = defineJobTable('jobs_thumbnail_generation');
export const JobsMetadataExtractionTable = defineJobTable('jobs_metadata_extraction');
export const JobsVideoConversionTable = defineJobTable('jobs_video_conversion');
export const JobsFaceDetectionTable = defineJobTable('jobs_face_detection');
export const JobsFacialRecognitionTable = defineJobTable('jobs_facial_recognition');
export const JobsSmartSearchTable = defineJobTable('jobs_smart_search');
export const JobsDuplicateDetectionTable = defineJobTable('jobs_duplicate_detection');
export const JobsBackgroundTaskTable = defineJobTable('jobs_background_task');
export const JobsStorageTemplateMigrationTable = defineJobTable('jobs_storage_template_migration');
export const JobsMigrationTable = defineJobTable('jobs_migration');
export const JobsSearchTable = defineJobTable('jobs_search');
export const JobsSidecarTable = defineJobTable('jobs_sidecar');
export const JobsLibraryTable = defineJobTable('jobs_library');
export const JobsNotificationTable = defineJobTable('jobs_notification');
export const JobsBackupDatabaseTable = defineJobTable('jobs_backup_database');
export const JobsOcrTable = defineJobTable('jobs_ocr');
export const JobsWorkflowTable = defineJobTable('jobs_workflow');
export const JobsEditorTable = defineJobTable('jobs_editor');
export type JobsThumbnailGenerationTable = InstanceType<typeof JobsThumbnailGenerationTable>;
export type JobsMetadataExtractionTable = InstanceType<typeof JobsMetadataExtractionTable>;
export type JobsVideoConversionTable = InstanceType<typeof JobsVideoConversionTable>;
export type JobsFaceDetectionTable = InstanceType<typeof JobsFaceDetectionTable>;
export type JobsFacialRecognitionTable = InstanceType<typeof JobsFacialRecognitionTable>;
export type JobsSmartSearchTable = InstanceType<typeof JobsSmartSearchTable>;
export type JobsDuplicateDetectionTable = InstanceType<typeof JobsDuplicateDetectionTable>;
export type JobsBackgroundTaskTable = InstanceType<typeof JobsBackgroundTaskTable>;
export type JobsStorageTemplateMigrationTable = InstanceType<typeof JobsStorageTemplateMigrationTable>;
export type JobsMigrationTable = InstanceType<typeof JobsMigrationTable>;
export type JobsSearchTable = InstanceType<typeof JobsSearchTable>;
export type JobsSidecarTable = InstanceType<typeof JobsSidecarTable>;
export type JobsLibraryTable = InstanceType<typeof JobsLibraryTable>;
export type JobsNotificationTable = InstanceType<typeof JobsNotificationTable>;
export type JobsBackupDatabaseTable = InstanceType<typeof JobsBackupDatabaseTable>;
export type JobsOcrTable = InstanceType<typeof JobsOcrTable>;
export type JobsWorkflowTable = InstanceType<typeof JobsWorkflowTable>;
export type JobsEditorTable = InstanceType<typeof JobsEditorTable>;
// Queue metadata table
@Table('job_queue_meta')
export class JobQueueMetaTable {
@PrimaryColumn({ type: 'text' })
queueName!: string;
@Column({ type: 'boolean', default: false })
isPaused!: Generated<boolean>;
}
// Dead-letter table for permanently failed jobs
@Table('job_failures')
@Index({ name: 'IDX_job_failures_queue', columns: ['queueName'] })
export class JobFailuresTable {
@PrimaryColumn({ type: 'bigint', identity: true })
id!: Generated<number>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
failedAt!: Generated<Date>;
@Column({ type: 'text' })
queueName!: QueueName;
@Column({ type: 'smallint' })
code!: JobCode;
@Column({ type: 'jsonb', nullable: true })
data!: unknown;
@Column({ type: 'text', nullable: true })
error!: string | null;
}

View File

@@ -18,7 +18,7 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -515,28 +515,19 @@ describe(AssetMediaService.name, () => {
});
it('should download edited file by default when edits exist', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getForOriginal.mockResolvedValue({
...editedAsset,
editedPath: '/uploads/user-id/fullsize/edited.jpg',
});
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, {})).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.files[3].path,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),
@@ -544,28 +535,19 @@ describe(AssetMediaService.name, () => {
});
it('should download edited file when edited=true', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getForOriginal.mockResolvedValue({
...editedAsset,
editedPath: '/uploads/user-id/fullsize/edited.jpg',
});
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: true })).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.files[3].path,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),
@@ -579,7 +561,9 @@ describe(AssetMediaService.name, () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path });
await expect(sut.downloadOriginal(authStub.adminSharedLink, editedAsset.id, { edited: false })).resolves.toEqual(
await expect(
sut.downloadOriginal(AuthFactory.from().sharedLink().build(), editedAsset.id, { edited: false }),
).resolves.toEqual(
new ImmichFileResponse({
path: fullsizeEdited.path,
fileName: editedAsset.originalFileName,
@@ -590,25 +574,19 @@ describe(AssetMediaService.name, () => {
});
it('should download original file when edited=false', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual(
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: false })).resolves.toEqual(
new ImmichFileResponse({
path: '/original/path.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.originalPath,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),

View File

@@ -7,7 +7,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -586,19 +585,19 @@ describe(AssetService.name, () => {
});
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
const asset = AssetFactory.from()
.stack({}, (builder) => builder.asset())
.build();
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage,
stack: {
id: 'stack-id',
primaryAssetId: assetStub.primaryImage.id,
assets: [{ id: 'one-asset' }],
},
...asset,
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
});
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
});
it('should delete a live photo', async () => {

View File

@@ -29,6 +29,7 @@ import {
UnsupportedPostgresError,
} from 'src/utils/database-backups';
import { ImmichFileResponse } from 'src/utils/file';
import { handlePromiseError } from 'src/utils/misc';
@Injectable()
export class DatabaseBackupService {
@@ -67,7 +68,7 @@ export class DatabaseBackupService {
this.cronRepository.create({
name: 'backupDatabase',
expression: database.cronExpression,
onTick: () => this.jobRepository.queue({ name: JobName.DatabaseBackup }),
onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.DatabaseBackup }), this.logger),
start: database.enabled,
});
}

View File

@@ -2,7 +2,6 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -184,13 +183,13 @@ describe(SearchService.name, () => {
});
it('should skip if asset is part of stack', async () => {
const id = assetStub.primaryImage.id;
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' });
const asset = AssetFactory.from().stack().build();
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId });
const result = await sut.handleSearchDuplicates({ id });
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`);
});
it('should skip if asset is not visible', async () => {

View File

@@ -53,7 +53,7 @@ export class JobService extends BaseService {
const response = await this.jobRepository.run(job);
await this.eventRepository.emit('JobSuccess', { job, response });
if (response && typeof response === 'string' && [JobStatus.Success, JobStatus.Skipped].includes(response)) {
void this.onDone(job).catch((error) => this.logger.error(`Failed to queue follow-up for ${job.name}: ${error}`));
await this.onDone(job);
}
} catch (error: Error | any) {
await this.eventRepository.emit('JobError', { job, error });

View File

@@ -47,7 +47,7 @@ export class LibraryService extends BaseService {
this.cronRepository.create({
name: CronJob.LibraryScan,
expression: scan.cronExpression,
onTick: () => this.jobRepository.queue({ name: JobName.LibraryScanQueueAll }),
onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.LibraryScanQueueAll }), this.logger),
start: scan.enabled,
});
}

View File

@@ -1,7 +1,7 @@
import { MapService } from 'src/services/map.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -16,36 +16,41 @@ describe(MapService.name, () => {
describe('getMapMarkers', () => {
it('should get geo information of assets', async () => {
const asset = assetStub.withLocation;
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
const markers = await sut.getMapMarkers(authStub.user1, {});
const markers = await sut.getMapMarkers(auth, {});
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker);
});
it('should include partner assets', async () => {
const partner = factory.partner();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
const auth = AuthFactory.create();
const partner = factory.partner({ sharedWithId: auth.user.id });
const asset = assetStub.withLocation;
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
@@ -62,21 +67,24 @@ describe(MapService.name, () => {
});
it('should include assets from shared albums', async () => {
const asset = assetStub.withLocation;
const auth = AuthFactory.create(userStub.user1);
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true });
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker);

View File

@@ -22,7 +22,6 @@ import {
import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
@@ -205,7 +204,8 @@ describe(MediaService.name, () => {
});
it('should queue assets with edits but missing edited thumbnails', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -213,7 +213,7 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetEditThumbnailGeneration,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
]);
@@ -221,8 +221,9 @@ describe(MediaService.name, () => {
});
it('should not queue assets with missing edited fullsize when feature is disabled', async () => {
const asset = AssetFactory.from().edit().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -251,7 +252,8 @@ describe(MediaService.name, () => {
});
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@@ -259,11 +261,11 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
{
name: JobName.AssetEditThumbnailGeneration,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
]);
@@ -1504,7 +1506,7 @@ describe(MediaService.name, () => {
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
@@ -2193,7 +2195,7 @@ describe(MediaService.name, () => {
});
it('should delete existing transcode if current policy does not require transcoding', async () => {
const asset = assetStub.hasEncodedVideo;
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);

View File

@@ -16,7 +16,6 @@ import {
import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
@@ -1227,16 +1226,17 @@ describe(MetadataService.name, () => {
expect(mocks.person.updateAll).not.toHaveBeenCalled();
});
it('should apply metadata face tags creating new persons', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
it('should apply metadata face tags creating new people', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
expect(mocks.person.createAll).toHaveBeenCalledWith([
expect.objectContaining({ name: personStub.withName.name }),
]);
@@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => {
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: 'random-uuid',
imageHeight: 100,
imageWidth: 1000,
@@ -1258,7 +1258,7 @@ describe(MetadataService.name, () => {
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
@@ -1269,21 +1269,22 @@ describe(MetadataService.name, () => {
});
it('should assign metadata face tags to existing persons', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
mocks.person.createAll.mockResolvedValue([]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
expect(mocks.person.createAll).not.toHaveBeenCalled();
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: personStub.withName.id,
imageHeight: 100,
imageWidth: 1000,
@@ -1353,16 +1354,17 @@ describe(MetadataService.name, () => {
'should transform RegionInfo geometry according to exif orientation $description',
async ({ orientation, expected }) => {
const { imgW, imgH, x1, x2, y1, y2 } = expected;
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, {
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
withHidden: true,
});
expect(mocks.person.createAll).toHaveBeenCalledWith([
@@ -1372,7 +1374,7 @@ describe(MetadataService.name, () => {
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: 'random-uuid',
imageWidth: imgW,
imageHeight: imgH,
@@ -1386,7 +1388,7 @@ describe(MetadataService.name, () => {
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{

View File

@@ -506,6 +506,7 @@ describe(NotificationService.name, () => {
it('should add new recipients for new images if job is already queued', async () => {
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2');
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyAlbumUpdate,
data: {

View File

@@ -218,6 +218,7 @@ export class NotificationService extends BaseService {
@OnEvent({ name: 'AlbumUpdate' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NotifyAlbumUpdate,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },

View File

@@ -28,9 +28,8 @@ import {
QueueName,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { isConcurrentQueue } from 'src/repositories/job.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem } from 'src/types';
import { ConcurrentQueueName, JobItem } from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
const asNightlyTasksCron = (config: SystemConfig) => {
@@ -81,7 +80,7 @@ export class QueueService extends BaseService {
onBootstrap() {
this.jobRepository.setup(this.services);
if (this.worker === ImmichWorker.Microservices) {
void this.jobRepository.startWorkers();
this.jobRepository.startWorkers();
}
}
@@ -89,7 +88,7 @@ export class QueueService extends BaseService {
this.logger.debug(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
let concurrency = 1;
if (isConcurrentQueue(queueName)) {
if (this.isConcurrentQueue(queueName)) {
concurrency = config.job[queueName].concurrency;
}
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
@@ -251,6 +250,15 @@ export class QueueService extends BaseService {
}
}
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {
return ![
QueueName.FacialRecognition,
QueueName.StorageTemplateMigration,
QueueName.DuplicateDetection,
QueueName.BackupDatabase,
].includes(name);
}
async handleNightlyJobs() {
const config = await this.getConfig({ withCache: false });
const jobs: JobItem[] = [];

View File

@@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -64,16 +65,18 @@ describe(SearchService.name, () => {
describe('getExploreData', () => {
it('should get assets by city and tag', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
mocks.asset.getAssetIdByCity.mockResolvedValue({
fieldName: 'exifInfo.city',
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
items: [{ value: 'city', data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
];
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
const result = await sut.getExploreData(authStub.user1);
const result = await sut.getExploreData(auth);
expect(result).toEqual(expectedResponse);
});

View File

@@ -1,7 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { StackService } from 'src/services/stack.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { stackStub } from 'test/fixtures/asset.stub';
import { AuthFactory } from 'test/factories/auth.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -20,12 +21,14 @@ describe(StackService.name, () => {
describe('search', () => {
it('should search stacks', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
mocks.stack.search.mockResolvedValue([stackStub('stack-id', [asset])]);
const stack = StackFactory.from().primaryAsset(asset).build();
mocks.stack.search.mockResolvedValue([stack]);
await sut.search(authStub.admin, { primaryAssetId: asset.id });
await sut.search(auth, { primaryAssetId: asset.id });
expect(mocks.stack.search).toHaveBeenCalledWith({
ownerId: authStub.admin.user.id,
ownerId: auth.user.id,
primaryAssetId: asset.id,
});
});
@@ -33,8 +36,10 @@ describe(StackService.name, () => {
describe('create', () => {
it('should require asset.update permissions', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
BadRequestException,
);
@@ -43,18 +48,22 @@ describe(StackService.name, () => {
});
it('should create a stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
id: 'stack-id',
mocks.stack.create.mockResolvedValue(stack);
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
id: stack.id,
primaryAssetId: primaryAsset.id,
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
});
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
stackId: stack.id,
userId: auth.user.id,
});
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
});
@@ -78,23 +87,26 @@ describe(StackService.name, () => {
});
it('should get stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
id: 'stack-id',
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
await expect(sut.get(auth, stack.id)).resolves.toEqual({
id: stack.id,
primaryAssetId: primaryAsset.id,
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
});
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
});
});
describe('update', () => {
it('should require stack.update permissions', async () => {
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.getById).not.toHaveBeenCalled();
expect(mocks.stack.update).not.toHaveBeenCalled();
@@ -104,7 +116,7 @@ describe(StackService.name, () => {
it('should fail if stack could not be found', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error);
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).not.toHaveBeenCalled();
@@ -112,57 +124,64 @@ describe(StackService.name, () => {
});
it('should fail if the provided primary asset id is not in the stack', async () => {
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const auth = AuthFactory.create();
const stack = StackFactory.from().primaryAsset().asset().build();
await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
expect(mocks.stack.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should update stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
await sut.update(authStub.admin, 'stack-id', { primaryAssetId: asset.id });
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
mocks.stack.update.mockResolvedValue(stack);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', {
id: 'stack-id',
await sut.update(auth, stack.id, { primaryAssetId: asset.id });
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, {
id: stack.id,
primaryAssetId: asset.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
stackId: stack.id,
userId: auth.user.id,
});
});
});
describe('delete', () => {
it('should require stack.delete permissions', async () => {
await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.delete).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should delete stack', async () => {
const auth = AuthFactory.create();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.delete.mockResolvedValue();
await sut.delete(authStub.admin, 'stack-id');
await sut.delete(auth, 'stack-id');
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
userId: auth.user.id,
});
});
});

View File

@@ -1,16 +1,14 @@
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { AssetPathType, JobStatus } from 'src/enum';
import { AssetPathType, AssetType, JobStatus } from 'src/enum';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { UserFactory } from 'test/factories/user.factory';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = assetStub.storageAsset({});
const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id });
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let mocks: ServiceMocks;
@@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => {
});
it('should migrate single moving picture', async () => {
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => {
});
it('should use handlebar if condition for album', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
@@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -166,14 +179,14 @@ describe(StorageTemplateService.name, () => {
});
it('should use handlebar else condition for album', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => {
});
it('should handle album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template =
@@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
mocks.album.getMetadataForIds.mockResolvedValueOnce([
{
@@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => {
});
it('should handle else condition from album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const config = structuredClone(defaults);
config.storageTemplate.template =
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
@@ -234,7 +247,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -248,11 +261,18 @@ describe(StorageTemplateService.name, () => {
});
it('should migrate previously failed move from original path when it still exists', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
const asset = assetStub.storageAsset();
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.user.get.mockResolvedValue(user);
const previousFailedNewPath = `/data/library/${user.id}/2023/Feb/${asset.originalFileName}`;
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
mocks.move.getByEntity.mockResolvedValue({
@@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -288,9 +308,16 @@ describe(StorageTemplateService.name, () => {
});
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 () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif({ fileSizeInByte: 5000 })
.build();
mocks.user.get.mockResolvedValue(user);
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
@@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -325,45 +352,53 @@ describe(StorageTemplateService.name, () => {
});
it('should fail move if copying and hash of asset and the new file do not match', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.user.get.mockResolvedValue(user);
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.create.mockResolvedValue({
id: '123',
entityId: testAsset.id,
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: testAsset.originalPath,
oldPath: asset.originalPath,
newPath,
});
await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id);
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1);
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath);
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: testAsset.id,
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: testAsset.originalPath,
oldPath: asset.originalPath,
newPath,
});
expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath);
expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath);
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath);
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
const testAsset = assetStub.storageAsset();
const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build();
it.each`
failedPathChecksum | failedPathSize | reason
${testAsset.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'}
failedPathChecksum | failedPathSize | reason
${testAsset.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.exifInfo.fileSizeInByte} | ${'checksum'}
`(
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => {
@@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => {
oldPath: testAsset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: testAsset.id,
@@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => {
});
it('should handle an asset with a duplicate destination', async () => {
const asset = assetStub.storageAsset();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
const newPath2 = newPath.replace('.jpg', '+1.jpg');
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset already matches the template', async () => {
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' });
const asset = AssetFactory.from({
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg',
})
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
@@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset is probably a duplicate', async () => {
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' });
const asset = AssetFactory.from({
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg',
})
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
@@ -471,10 +519,15 @@ describe(StorageTemplateService.name, () => {
});
it('should move an asset', async () => {
const asset = assetStub.storageAsset();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => {
});
it('should use the user storage label', async () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({ ownerId: user.id });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create({ storageLabel: 'label-1' });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
asset.originalPath,
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).toHaveBeenCalledWith({
@@ -520,10 +579,16 @@ describe(StorageTemplateService.name, () => {
});
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/path/to/original.jpg',
})
.exif({ fileSizeInByte: 5000 })
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
@@ -561,10 +626,17 @@ describe(StorageTemplateService.name, () => {
});
it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
const asset = assetStub.storageAsset();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.stat).toHaveBeenCalledWith(
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should not update the database if the move fails', async () => {
const asset = assetStub.storageAsset();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue(new Error('Read only system'));
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system'));
mocks.move.create.mockResolvedValue({
@@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: '',
});
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.user.getList.mockResolvedValue([user]);
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should migrate live photo motion video alongside the still image', async () => {
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => {
describe('file rename correctness', () => {
it('should not create double extensions when filename has lower extension', async () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create({ storageLabel: 'label-1' });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => {
});
it('should not create double extensions when filename has uppercase extension', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif({ fileSizeInByte: 12_345 })
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => {
});
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
originalFileName: 'IMG_7065.JPEG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => {
});
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
originalFileName: 'IMG_7065.JPG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',

View File

@@ -134,8 +134,8 @@ export class StorageTemplateService extends BaseService {
}
@OnEvent({ name: 'AssetMetadataExtracted' })
onAssetMetadataExtracted({ source, assetId }: ArgOf<'AssetMetadataExtracted'>) {
this.jobRepository.queue({ name: JobName.StorageTemplateMigrationSingle, data: { source, id: assetId } });
async onAssetMetadataExtracted({ source, assetId }: ArgOf<'AssetMetadataExtracted'>) {
await this.jobRepository.queue({ name: JobName.StorageTemplateMigrationSingle, data: { source, id: assetId } });
}
@OnJob({ name: JobName.StorageTemplateMigrationSingle, queue: QueueName.StorageTemplateMigration })

View File

@@ -12,7 +12,6 @@ export const compareParameters = (): Comparer<DatabaseParameter> => ({
{
type: 'ParameterReset',
databaseName: target.databaseName,
tableName: target.tableName,
parameterName: target.name,
reason: Reason.MissingInSource,
},

View File

@@ -3,22 +3,11 @@ import { Processor } from 'src/sql-tools/types';
export const processConfigurationParameters: Processor = (ctx, items) => {
for (const {
item: { object, options },
item: { options },
} of items.filter((item) => item.type === 'configurationParameter')) {
let tableName: string | undefined;
if (options.scope === 'table') {
const table = ctx.getTableByObject(object);
if (!table) {
ctx.warn('@ConfigurationParameter', `Unable to find table for table-scoped parameter "${options.name}"`);
continue;
}
tableName = table.name;
}
ctx.parameters.push({
databaseName: ctx.databaseName,
tableName,
name: tableName ? `${tableName}.${options.name}` : options.name,
name: options.name,
value: fromColumnValue(options.value),
scope: options.scope,
synchronize: options.synchronize ?? true,

View File

@@ -17,11 +17,11 @@ import { Processor } from 'src/sql-tools/types';
export const processors: Processor[] = [
processDatabases,
processConfigurationParameters,
processEnums,
processExtensions,
processFunctions,
processTables,
processConfigurationParameters,
processColumns,
processForeignKeyColumns,
processForeignKeyConstraints,

View File

@@ -17,36 +17,4 @@ export const readParameters: Reader = async (ctx, db) => {
synchronize: true,
});
}
// Read table-scoped storage parameters from pg_class.reloptions
const tableParams = await db
.selectFrom('pg_class')
.innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_class.relnamespace')
.where('pg_namespace.nspname', '=', ctx.schemaName)
.where('pg_class.relkind', '=', sql.lit('r'))
.where('pg_class.reloptions', 'is not', null)
.select(['pg_class.relname as table_name', 'pg_class.reloptions'])
.execute();
for (const row of tableParams) {
if (!row.reloptions) {
continue;
}
for (const option of row.reloptions) {
const eqIdx = option.indexOf('=');
if (eqIdx === -1) {
continue;
}
const name = option.slice(0, eqIdx);
const value = option.slice(eqIdx + 1);
ctx.parameters.push({
name: `${row.table_name}.${name}`,
tableName: row.table_name,
value,
databaseName: ctx.databaseName,
scope: 'table',
synchronize: true,
});
}
}
};

View File

@@ -73,13 +73,13 @@ export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, optio
const orderedItems = [
...itemMap.ExtensionCreate,
...itemMap.FunctionCreate,
...itemMap.ParameterSet,
...itemMap.ParameterReset,
...itemMap.EnumCreate,
...itemMap.TriggerDrop,
...itemMap.IndexDrop,
...itemMap.ConstraintDrop,
...itemMap.TableCreate,
...itemMap.ParameterSet,
...itemMap.ParameterReset,
...itemMap.ColumnAlter,
...itemMap.ColumnAdd,
...itemMap.ColumnRename,

View File

@@ -8,7 +8,7 @@ export const transformParameters: SqlTransformer = (ctx, item) => {
}
case 'ParameterReset': {
return asParameterReset(item.databaseName, item.parameterName, item.tableName);
return asParameterReset(item.databaseName, item.parameterName);
}
default: {
@@ -17,19 +17,7 @@ export const transformParameters: SqlTransformer = (ctx, item) => {
}
};
const getParameterName = (parameter: DatabaseParameter): string => {
if (parameter.scope === 'table' && parameter.tableName && parameter.name.startsWith(`${parameter.tableName}.`)) {
return parameter.name.slice(parameter.tableName.length + 1);
}
return parameter.name;
};
const asParameterSet = (parameter: DatabaseParameter): string => {
if (parameter.scope === 'table' && parameter.tableName) {
const paramName = getParameterName(parameter);
return `ALTER TABLE "${parameter.tableName}" SET (${paramName} = ${parameter.value})`;
}
let sql = '';
if (parameter.scope === 'database') {
sql += `ALTER DATABASE "${parameter.databaseName}" `;
@@ -40,9 +28,6 @@ const asParameterSet = (parameter: DatabaseParameter): string => {
return sql;
};
const asParameterReset = (databaseName: string, parameterName: string, tableName?: string): string => {
if (tableName) {
return `ALTER TABLE "${tableName}" RESET (${parameterName})`;
}
const asParameterReset = (databaseName: string, parameterName: string): string => {
return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`;
};

View File

@@ -86,7 +86,6 @@ export type PostgresDB = {
relhasindex: PostgresYesOrNo;
relisshared: PostgresYesOrNo;
relpersistence: string;
reloptions: string[] | null;
};
pg_constraint: {
@@ -307,7 +306,6 @@ export enum ActionType {
export type ColumnStorage = 'default' | 'external' | 'extended' | 'main';
export type ColumnType =
| '"char"'
| 'bigint'
| 'boolean'
| 'bytea'
@@ -318,7 +316,6 @@ export type ColumnType =
| 'integer'
| 'jsonb'
| 'polygon'
| 'smallint'
| 'text'
| 'time'
| 'time with time zone'
@@ -347,13 +344,12 @@ export type DatabaseSchema = {
export type DatabaseParameter = {
name: string;
databaseName: string;
tableName?: string;
value: string | number | null | undefined;
scope: ParameterScope;
synchronize: boolean;
};
export type ParameterScope = 'database' | 'table' | 'user';
export type ParameterScope = 'database' | 'user';
export type DatabaseOverride = {
name: string;
@@ -510,7 +506,7 @@ export type SchemaDiff = { reason: string } & (
| { type: 'TriggerCreate'; trigger: DatabaseTrigger }
| { type: 'TriggerDrop'; tableName: string; triggerName: string }
| { type: 'ParameterSet'; parameter: DatabaseParameter }
| { type: 'ParameterReset'; databaseName: string; tableName?: string; parameterName: string }
| { type: 'ParameterReset'; databaseName: string; parameterName: string }
| { type: 'EnumCreate'; enum: DatabaseEnum }
| { type: 'EnumDrop'; enumName: string }
| { type: 'OverrideCreate'; override: DatabaseOverride }

View File

@@ -1,470 +0,0 @@
import { Kysely, Selectable, sql } from 'kysely';
import postgres from 'postgres';
import { JOB_CODE_TO_NAME, JobCode, JobQueueStatus, QueueName } from 'src/enum';
import { DB } from 'src/schema';
import { JobTable } from 'src/schema/tables/job.table';
import { JobItem } from 'src/types';
const csvEscape = (s: string) => '"' + s.replace(/"/g, '""') + '"';
export type InsertRow = {
code: JobCode;
data: unknown;
priority: number | null;
dedupKey: string | null;
runAfter: Date | null;
};
export const getTable = (db: Kysely<DB>, queueName: QueueName) => db.dynamic.table(QUEUE_TABLE[queueName]).as('t');
export class QueueWorker {
activeJobCount = 0;
private concurrency: number;
private activeJobs = new Map<number, { startedAt: number }>();
private hasPending = true;
private fetching = false;
private paused = false;
private stopped = false;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private sweepTimer: ReturnType<typeof setTimeout> | null = null;
private readonly queueName: QueueName;
private readonly table: ReturnType<typeof getTable>;
private readonly stallTimeout: number;
private readonly claimBatch: number;
private readonly maxRetries: number;
private readonly backoffBaseMs: number;
private readonly db: Kysely<DB>;
private readonly onJobFn: (job: JobItem) => Promise<unknown>;
constructor(options: QueueWorkerOptions) {
this.queueName = options.queueName;
this.stallTimeout = options.stallTimeout;
this.claimBatch = options.claimBatch;
this.maxRetries = options.maxRetries;
this.backoffBaseMs = options.backoffBaseMs;
this.concurrency = options.concurrency;
this.db = options.db;
this.table = getTable(this.db, options.queueName);
this.onJobFn = options.onJob;
// One-shot sweep after stallTimeout to recover jobs orphaned by a crash
// that restarted before their expiry passed
this.sweepTimer = setTimeout(() => this.onNotification(), this.stallTimeout);
}
onNotification() {
this.hasPending = true;
void this.tryFetch();
}
setConcurrency(n: number) {
this.concurrency = n;
void this.tryFetch();
}
pause() {
this.paused = true;
}
resume() {
this.paused = false;
this.hasPending = true;
void this.tryFetch();
}
shutdown() {
this.stopped = true;
this.stopHeartbeat();
if (this.sweepTimer) {
clearTimeout(this.sweepTimer);
this.sweepTimer = null;
}
if (this.activeJobs.size === 0) {
return Promise.resolve();
}
// Re-queue active jobs
const ids = [...this.activeJobs.keys()];
return this.db
.updateTable(this.table)
.set({
status: JobQueueStatus.Pending,
startedAt: null,
expiresAt: null,
})
.where('id', 'in', ids)
.execute();
}
private get slotsAvailable() {
return Math.max(0, this.concurrency - this.activeJobCount);
}
private async tryFetch() {
if (this.fetching || this.paused || this.stopped) {
return;
}
this.fetching = true;
try {
while (this.slotsAvailable > 0 && this.hasPending && !this.stopped) {
const limit = Math.min(this.slotsAvailable, this.claimBatch);
const jobs = await this.claim(limit);
if (jobs.length === 0) {
const recovered = await this.recoverStalled();
if (recovered.numChangedRows === 0n) {
this.hasPending = false;
break;
}
continue;
}
this.activeJobCount += jobs.length;
for (const job of jobs) {
void this.processJob(job);
}
}
} finally {
this.fetching = false;
}
}
private async processJob(row: Selectable<JobTable>) {
this.activeJobs.set(row.id, { startedAt: Date.now() });
this.startHeartbeat();
try {
const jobName = JOB_CODE_TO_NAME[row.code];
if (!jobName) {
throw new Error(`Unknown job char code: ${row.code}`);
}
await this.onJobFn({ name: jobName, data: row.data } as JobItem);
// Success: delete completed job and try to fetch next
const next = this.stopped ? undefined : await this.completeAndFetch(row.id).catch(() => undefined);
this.activeJobs.delete(row.id);
if (next) {
void this.processJob(next);
} else {
this.activeJobCount--;
this.hasPending = false;
}
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
const next =
row.retries < this.maxRetries
? await this.retryAndFetch(row.id, row.retries).catch(() => undefined)
: await this.deadLetterAndFetch(row, errorMsg).catch(() => undefined);
this.activeJobs.delete(row.id);
if (next) {
void this.processJob(next);
} else {
this.activeJobCount--;
this.hasPending = false;
}
} finally {
if (this.activeJobs.size === 0) {
this.stopHeartbeat();
}
}
}
/**
* Claim up to `limit` pending jobs.
* Uses a materialized CTE with FOR NO KEY UPDATE SKIP LOCKED
* to avoid race conditions and excessive locking.
*/
private claim(limit: number) {
return this.db
.with(
(wb) => wb('candidates').materialized(),
(qb) =>
qb
.selectFrom(this.table)
.select('id')
.where('status', '=', JobQueueStatus.Pending)
.where('runAfter', '<=', sql<Date>`now()`)
.orderBy('priority', 'desc')
.orderBy('id', 'asc')
.limit(limit)
.forNoKeyUpdate()
.skipLocked(),
)
.updateTable(this.table)
.set({
status: JobQueueStatus.Active,
startedAt: sql<Date>`now()`,
expiresAt: sql<Date>`now() + ${sql.lit(`'${this.stallTimeout} milliseconds'`)}::interval`,
})
.where((eb) => eb('id', 'in', eb.selectFrom('candidates').select('id')))
.returningAll()
.execute();
}
/**
* Atomically delete a completed job and claim the next one.
*/
private completeAndFetch(jobId: number) {
const prefix = this.db.with('mark', (qb: any) => qb.deleteFrom(this.table).where('id', '=', jobId));
return this.claimNext(prefix as any);
}
/**
* Atomically retry a failed job (reset to pending with backoff) and claim the next ready one.
*/
private retryAndFetch(jobId: number, retries: number) {
const backoffMs = this.backoffBaseMs * 5 ** retries;
const prefix = this.db.with('mark', (qb) =>
qb
.updateTable(this.table)
.set({
status: JobQueueStatus.Pending,
retries: retries + 1,
runAfter: sql<Date>`now() + ${sql.lit(`'${backoffMs} milliseconds'`)}::interval`,
startedAt: null,
expiresAt: null,
})
.where('id', '=', jobId),
);
return this.claimNext(prefix as any);
}
/**
* Atomically delete a permanently failed job, log it to the dead-letter table, and claim the next one.
*/
private deadLetterAndFetch(row: Selectable<JobTable>, errorMsg: string) {
const prefix = this.db
.with('mark', (qb) => qb.deleteFrom(this.table).where('id', '=', row.id))
.with('logged', (qb) =>
qb.insertInto('job_failures').values({
queueName: this.queueName,
code: row.code,
data: row.data,
error: errorMsg,
}),
);
return this.claimNext(prefix as any);
}
/**
* Shared suffix: claim the next pending job. Appended after prefix CTEs (mark, logged, etc.).
*/
private claimNext(prefix: Kysely<DB>) {
return prefix
.with('next', (qb) =>
qb
.selectFrom(this.table)
.select('id')
.where('status', '=', JobQueueStatus.Pending)
.where('runAfter', '<=', sql<Date>`now()`)
.orderBy('priority', 'desc')
.orderBy('id', 'asc')
.limit(1)
.forNoKeyUpdate()
.skipLocked(),
)
.updateTable(this.table)
.set({
status: JobQueueStatus.Active,
startedAt: sql<Date>`now()`,
expiresAt: sql<Date>`now() + ${sql.lit(`'${this.stallTimeout} milliseconds'`)}::interval`,
})
.where((eb) => eb('id', '=', eb.selectFrom('next').select('id')))
.returningAll()
.executeTakeFirst();
}
/**
* Recover stalled jobs: reset jobs whose expires_at has passed
*/
private recoverStalled() {
return this.db
.updateTable(this.table)
.set({
status: JobQueueStatus.Pending,
startedAt: null,
expiresAt: null,
})
.where('status', '=', JobQueueStatus.Active)
.where('expiresAt', '<', sql<Date>`now()`) // needed for multi-instance safety
.executeTakeFirst();
}
/**
* Extend expiry for all active jobs (heartbeat)
*/
private extendExpiry() {
if (this.activeJobs.size === 0) {
return;
}
const ids = [...this.activeJobs.keys()];
return this.db
.updateTable(this.table)
.set({
expiresAt: sql<Date>`now() + ${sql.lit(`'${this.stallTimeout} milliseconds'`)}::interval`,
})
.where('id', 'in', ids)
.execute();
}
private startHeartbeat() {
if (this.heartbeatTimer) {
return;
}
this.heartbeatTimer = setInterval(
() => this.extendExpiry()?.catch(() => setTimeout(() => this.extendExpiry(), 5000)),
Math.floor(this.stallTimeout / 2),
);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}
export class WriteBuffer {
private buffers = Object.fromEntries(Object.values(QueueName).map((name) => [name as QueueName, [] as InsertRow[]]));
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private pgPool: postgres.Sql,
private notify: (queue: QueueName) => Promise<unknown>,
private onFlushError?: (error: unknown) => void,
) {}
add(items: { queue: QueueName; row: InsertRow }[]): void {
if (items.length === 0) {
return;
}
for (const { queue, row } of items) {
this.buffers[queue].push(row);
}
if (!this.timer) {
this.timer = setTimeout(() => void this.flush().catch((error) => this.onFlushError?.(error)), 10);
}
}
async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const promises: Promise<unknown>[] = [];
for (const [queue, rows] of Object.entries(this.buffers)) {
if (rows.length === 0) {
continue;
}
const queueName = queue as QueueName;
const tableName = QUEUE_TABLE[queueName];
const copyRows: InsertRow[] = [];
const insertRows: InsertRow[] = [];
for (const row of rows) {
if (row.dedupKey) {
insertRows.push(row);
} else {
copyRows.push(row);
}
}
rows.length = 0;
if (copyRows.length > 0) {
promises.push(this.copyInsert(tableName, copyRows).then(() => this.notify(queueName)));
}
if (insertRows.length > 0) {
promises.push(this.insertChunk(tableName, insertRows).then(() => this.notify(queueName)));
}
}
await Promise.all(promises);
}
private async copyInsert(tableName: string, rows: InsertRow[]) {
const conn = await this.pgPool.reserve();
try {
const writable = await conn`COPY ${conn(tableName)} (code, data, priority, "runAfter") FROM STDIN WITH (FORMAT csv)`.writable();
const now = new Date().toISOString();
for (const row of rows) {
const data = row.data != null ? csvEscape(JSON.stringify(row.data)) : '';
const priority = row.priority ?? 0;
const runAfter = row.runAfter ? row.runAfter.toISOString() : now;
writable.write(`${row.code},${data},${priority},${runAfter}\n`);
}
writable.end();
await new Promise<void>((resolve, reject) => {
writable.on('finish', resolve);
writable.on('error', reject);
});
} finally {
conn.release();
}
}
private insertChunk(tableName: string, rows: InsertRow[]) {
const now = new Date().toISOString();
const code = [];
const data = [];
const priority = [];
const dedupKey = [];
const runAfter = [];
for (const row of rows) {
code.push(row.code);
data.push(row.data ?? null);
priority.push(row.priority ?? 0);
dedupKey.push(row.dedupKey);
runAfter.push(row.runAfter?.toISOString() ?? now);
}
return this.pgPool`
INSERT INTO ${this.pgPool(tableName)} (code, data, priority, "dedupKey", "runAfter")
SELECT * FROM unnest(
${code}::smallint[],
${data as any}::jsonb[],
${priority}::smallint[],
${dedupKey}::text[],
${runAfter}::timestamptz[]
)
ON CONFLICT ("dedupKey") WHERE "dedupKey" IS NOT NULL
DO UPDATE SET "runAfter" = EXCLUDED."runAfter", data = EXCLUDED.data
WHERE ${this.pgPool(tableName)}.status = ${JobQueueStatus.Pending}
`;
}
}
const QUEUE_TABLE = {
[QueueName.ThumbnailGeneration]: 'jobs_thumbnail_generation',
[QueueName.MetadataExtraction]: 'jobs_metadata_extraction',
[QueueName.VideoConversion]: 'jobs_video_conversion',
[QueueName.FaceDetection]: 'jobs_face_detection',
[QueueName.FacialRecognition]: 'jobs_facial_recognition',
[QueueName.SmartSearch]: 'jobs_smart_search',
[QueueName.DuplicateDetection]: 'jobs_duplicate_detection',
[QueueName.BackgroundTask]: 'jobs_background_task',
[QueueName.StorageTemplateMigration]: 'jobs_storage_template_migration',
[QueueName.Migration]: 'jobs_migration',
[QueueName.Search]: 'jobs_search',
[QueueName.Sidecar]: 'jobs_sidecar',
[QueueName.Library]: 'jobs_library',
[QueueName.Notification]: 'jobs_notification',
[QueueName.BackupDatabase]: 'jobs_backup_database',
[QueueName.Ocr]: 'jobs_ocr',
[QueueName.Workflow]: 'jobs_workflow',
[QueueName.Editor]: 'jobs_editor',
} as const;
interface QueueWorkerOptions {
queueName: QueueName;
stallTimeout: number;
claimBatch: number;
maxRetries: number;
backoffBaseMs: number;
concurrency: number;
db: Kysely<DB>;
onJob: (job: JobItem) => Promise<unknown>;
}

View File

@@ -1,60 +1,11 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
import { StorageFolder } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,

View File

@@ -1,14 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { ApiModule } from 'src/app.module';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
export async function bootstrap() {
process.title = 'immich-api';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
@@ -19,10 +26,12 @@ async function bootstrap() {
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
if (!isMainThread || process.send) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}

View File

@@ -1,13 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { MaintenanceModule } from 'src/app.module';
import { SocketIoAdapter } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AppRepository } from 'src/repositories/app.repository';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
export async function bootstrap() {
process.title = 'immich-maintenance';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
@@ -16,13 +25,18 @@ async function bootstrap() {
void configureExpress(app, {
permitSwaggerWrite: false,
ssr: MaintenanceWorkerService,
// Use BroadcastChannel instead of Postgres adapter to avoid crash when
// pg_terminate_backend() kills all database connections during restore
socketIoAdapter: SocketIoAdapter.BroadcastChannel,
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
if (!isMainThread) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}

View File

@@ -1,8 +1,9 @@
import { NestFactory } from '@nestjs/core';
import { isMainThread } from 'node:worker_threads';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -10,6 +11,11 @@ import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.microservicesPort);
@@ -24,7 +30,7 @@ export async function bootstrap() {
logger.setContext('Bootstrap');
app.useLogger(logger);
app.useWebSocketAdapter(new WebSocketAdapter(app));
app.useWebSocketAdapter(await createWebSocketAdapter(app));
await (host ? app.listen(0, host) : app.listen(0));

View File

@@ -0,0 +1,47 @@
import { Selectable } from 'kysely';
import { SourceType } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { build } from 'test/factories/builder.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { AssetFaceLike, FactoryBuilder, PersonLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFaceFactory {
#person: PersonFactory | null = null;
private constructor(private readonly value: Selectable<AssetFaceTable>) {}
static create(dto: AssetFaceLike = {}) {
return AssetFaceFactory.from(dto).build();
}
static from(dto: AssetFaceLike = {}) {
return new AssetFaceFactory({
assetId: newUuid(),
boundingBoxX1: 11,
boundingBoxX2: 12,
boundingBoxY1: 21,
boundingBoxY2: 22,
deletedAt: null,
id: newUuid(),
imageHeight: 42,
imageWidth: 420,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
person(dto: PersonLike = {}, builder?: FactoryBuilder<PersonFactory>) {
this.#person = build(PersonFactory.from(dto), builder);
this.value.personId = this.#person.build().id;
return this;
}
build() {
return { ...this.value, person: this.#person?.build() ?? null };
}
}

View File

@@ -1,12 +1,23 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
import { AssetExifFactory } from 'test/factories/asset-exif.factory';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { StackFactory } from 'test/factories/stack.factory';
import {
AssetEditLike,
AssetExifLike,
AssetFaceLike,
AssetFileLike,
AssetLike,
FactoryBuilder,
StackLike,
UserLike,
} from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
@@ -15,7 +26,8 @@ export class AssetFactory {
#assetExif?: AssetExifFactory;
#files: AssetFileFactory[] = [];
#edits: AssetEditFactory[] = [];
#faces: Selectable<AssetFaceTable>[] = [];
#faces: AssetFaceFactory[] = [];
#stack?: Selectable<StackTable> & { assets: Selectable<AssetTable>[]; primaryAsset: Selectable<AssetTable> };
private constructor(private readonly value: Selectable<AssetTable>) {
value.ownerId ??= newUuid();
@@ -83,8 +95,8 @@ export class AssetFactory {
return this;
}
face(dto: Selectable<AssetFaceTable>) {
this.#faces.push(dto);
face(dto: AssetFaceLike = {}, builder?: FactoryBuilder<AssetFaceFactory>) {
this.#faces.push(build(AssetFaceFactory.from(dto), builder));
return this;
}
@@ -117,6 +129,12 @@ export class AssetFactory {
return this;
}
stack(dto: StackLike = {}, builder?: FactoryBuilder<StackFactory>) {
this.#stack = build(StackFactory.from(dto).primaryAsset(this.value), builder).build();
this.value.stackId = this.#stack.id;
return this;
}
build() {
const exif = this.#assetExif?.build();
@@ -126,8 +144,9 @@ export class AssetFactory {
exifInfo: exif as NonNullable<typeof exif>,
files: this.#files.map((file) => file.build()),
edits: this.#edits.map((edit) => edit.build()),
faces: this.#faces,
stack: null,
faces: this.#faces.map((face) => face.build()),
stack: this.#stack ?? null,
tags: [],
};
}
}

View File

@@ -0,0 +1,34 @@
import { Selectable } from 'kysely';
import { PersonTable } from 'src/schema/tables/person.table';
import { PersonLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class PersonFactory {
private constructor(private readonly value: Selectable<PersonTable>) {}
static create(dto: PersonLike = {}) {
return PersonFactory.from(dto).build();
}
static from(dto: PersonLike = {}) {
return new PersonFactory({
birthDate: null,
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',
ownerId: newUuid(),
thumbnailPath: '/data/thumbs/person-thumbnail.jpg',
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
build() {
return { ...this.value };
}
}

View File

@@ -0,0 +1,52 @@
import { Selectable } from 'kysely';
import { StackTable } from 'src/schema/tables/stack.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetLike, FactoryBuilder, StackLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class StackFactory {
#assets: AssetFactory[] = [];
#primaryAsset: AssetFactory;
private constructor(private readonly value: Selectable<StackTable>) {
this.#primaryAsset = AssetFactory.from();
this.value.primaryAssetId = this.#primaryAsset.build().id;
}
static create(dto: StackLike = {}) {
return StackFactory.from(dto).build();
}
static from(dto: StackLike = {}) {
return new StackFactory({
createdAt: newDate(),
id: newUuid(),
ownerId: newUuid(),
primaryAssetId: newUuid(),
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
this.#assets.push(build(AssetFactory.from(dto), builder));
return this;
}
primaryAsset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
this.#primaryAsset = build(AssetFactory.from(dto), builder);
this.value.primaryAssetId = this.#primaryAsset.build().id;
this.#assets.push(this.#primaryAsset);
return this;
}
build() {
return {
...this.value,
assets: this.#assets.map((asset) => asset.build()),
primaryAsset: this.#primaryAsset.build(),
};
}
}

View File

@@ -3,9 +3,12 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
@@ -18,3 +21,6 @@ export type AlbumLike = Partial<Selectable<AlbumTable>>;
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type StackLike = Partial<Selectable<StackTable>>;

View File

@@ -1,298 +0,0 @@
import { Exif } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
export const previewFile = factory.assetFile({ type: AssetFileType.Preview });
const thumbnailFile = factory.assetFile({
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/webp/path.ext',
});
const fullsizeFile = factory.assetFile({
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path.webp',
});
const files = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return {
id: stackId,
assets,
ownerId: assets[0].ownerId,
primaryAsset: assets[0],
primaryAssetId: assets[0].id,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
updateId: expect.any(String),
};
};
export const assetStub = {
storageAsset: (asset: Partial<StorageAsset> = {}) => ({
id: 'asset-id',
ownerId: 'user-id',
livePhotoVideoId: null,
type: AssetType.Image,
isExternal: false,
checksum: Buffer.from('file hash'),
timeZone: null,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/original/path.jpg',
originalFileName: 'IMG_123.jpg',
fileSizeInByte: 12_345,
files: [],
make: 'FUJIFILM',
model: 'X-T50',
lensModel: 'XF27mm F2.8 R WR',
isEdited: false,
...asset,
}),
primaryImage: Object.freeze({
id: 'primary-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.admin,
ownerId: 'admin-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
files,
type: AssetType.Image,
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,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 1000,
exifImageWidth: 1000,
} as Exif,
stackId: 'stack-1',
stack: stackStub('stack-1', [
{ id: 'primary-asset-id' } as MapAsset & { exifInfo: Exif },
{ id: 'stack-child-asset-1' } as MapAsset & { exifInfo: Exif },
{ id: 'stack-child-asset-2' } as MapAsset & { exifInfo: Exif },
]),
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
withLocation: Object.freeze({
id: 'asset-with-favorite-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
checksum: Buffer.from('file hash', 'utf8'),
originalPath: '/original/path.ext',
type: AssetType.Image,
files: [previewFile],
thumbhash: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-22T05:06:29.716Z'),
updatedAt: new Date('2023-02-22T05:06:29.716Z'),
localDateTime: new Date('2020-12-31T23:59:00.000Z'),
isFavorite: false,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
latitude: 100,
longitude: 100,
fileSizeInByte: 23_456,
city: 'test-city',
state: 'test-state',
country: 'test-country',
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
tags: [],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
hasEncodedVideo: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
originalFileName: 'asset-id.ext',
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',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Video,
files: [previewFile],
thumbhash: null,
encodedVideoPath: '/encoded/video/path.mp4',
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,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
exifInfo: {
fileSizeInByte: 100_000,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageDng: 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.dng',
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,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.dng',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
withCropEdit: 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',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
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('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [
{
action: AssetEditAction.Crop,
parameters: {
width: 1512,
height: 1152,
x: 216,
y: 1512,
},
},
] as AssetEditActionItem[],
isEdited: true,
}),
};

View File

@@ -1,5 +1,5 @@
import { AssetType } from 'src/enum';
import { previewFile } from 'test/fixtures/asset.stub';
import { AssetFileType, AssetType } from 'src/enum';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { userStub } from 'test/fixtures/user.stub';
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
@@ -179,7 +179,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
newThumbnailMiddle: Object.freeze({
ownerId: userStub.admin.id,
@@ -192,7 +192,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
newThumbnailEnd: Object.freeze({
ownerId: userStub.admin.id,
@@ -205,7 +205,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
rawEmbeddedThumbnail: Object.freeze({
ownerId: userStub.admin.id,
@@ -218,7 +218,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.dng',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
negativeCoordinate: Object.freeze({
ownerId: userStub.admin.id,
@@ -231,7 +231,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
overflowingCoordinate: Object.freeze({
ownerId: userStub.admin.id,
@@ -244,7 +244,7 @@ export const personThumbnailStub = {
type: AssetType.Image,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
videoThumbnail: Object.freeze({
ownerId: userStub.admin.id,
@@ -257,6 +257,6 @@ export const personThumbnailStub = {
type: AssetType.Video,
originalPath: '/original/path.mp4',
exifOrientation: '1',
previewPath: previewFile.path,
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
}),
};

22
server/test/mappers.ts Normal file
View File

@@ -0,0 +1,22 @@
import { AssetFactory } from 'test/factories/asset.factory';
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
return {
id: asset.id,
ownerId: asset.ownerId,
livePhotoVideoId: asset.livePhotoVideoId,
type: asset.type,
isExternal: asset.isExternal,
checksum: asset.checksum,
timeZone: asset.exifInfo.timeZone,
fileCreatedAt: asset.fileCreatedAt,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
fileSizeInByte: asset.exifInfo.fileSizeInByte,
files: asset.files,
make: asset.exifInfo.make,
model: asset.exifInfo.model,
lensModel: asset.exifInfo.lensModel,
isEdited: asset.isEdited,
};
};

View File

@@ -0,0 +1,276 @@
import { ClusterMessage, ClusterResponse } from 'socket.io-adapter';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { vi } from 'vitest';
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: {
encode: vi.fn().mockReturnValue([]),
},
_opts: {},
sockets: {
sockets: new Map(),
},
},
});
describe('BroadcastChannelAdapter', () => {
describe('createBroadcastChannelAdapter', () => {
it('should return a factory function', () => {
const factory = createBroadcastChannelAdapter();
expect(typeof factory).toBe('function');
});
it('should create adapter instance when factory is called', () => {
const mockNamespace = createMockNamespace();
const factory = createBroadcastChannelAdapter();
const adapter = factory(mockNamespace);
expect(adapter).toBeDefined();
expect(adapter.doPublish).toBeDefined();
expect(adapter.doPublishResponse).toBeDefined();
adapter.close();
});
});
describe('BroadcastChannelAdapter message passing', () => {
it('should actually send and receive messages between two adapters', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
resolve();
return originalOnMessage(message);
};
});
const testMessage = {
type: 2,
data: {
opts: { rooms: new Set(['room1']) },
rooms: ['room1'],
},
nsp: '/',
};
void adapter1.doPublish(testMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(receivedMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
it('should send ConfigUpdate-style event and receive it on another adapter', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
if ((message as any)?.data?.event === 'ConfigUpdate') {
resolve();
}
return originalOnMessage(message);
};
});
const configUpdateMessage = {
type: 2,
data: {
event: 'ConfigUpdate',
payload: { newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } },
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter1.doPublish(configUpdateMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
const configMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'ConfigUpdate');
expect(configMessages.length).toBeGreaterThan(0);
expect((configMessages[0] as any).data.payload.newConfig.ffmpeg.crf).toBe(23);
adapter1.close();
adapter2.close();
});
it('should send AppRestart-style event and receive it on another adapter', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: ClusterMessage[] = [];
const messageReceived = new Promise<void>((resolve) => {
const originalOnMessage = adapter2.onMessage.bind(adapter2);
adapter2.onMessage = (message: ClusterMessage) => {
receivedMessages.push(message);
if ((message as any)?.data?.event === 'AppRestart') {
resolve();
}
return originalOnMessage(message);
};
});
const appRestartMessage = {
type: 2,
data: {
event: 'AppRestart',
payload: { isMaintenanceMode: true },
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter1.doPublish(appRestartMessage as any);
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
const restartMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'AppRestart');
expect(restartMessages.length).toBeGreaterThan(0);
expect((restartMessages[0] as any).data.payload.isMaintenanceMode).toBe(true);
adapter1.close();
adapter2.close();
});
it('should not receive its own messages (echo prevention)', async () => {
const factory = createBroadcastChannelAdapter();
const namespace = createMockNamespace();
const adapter = factory(namespace);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedOwnMessages: ClusterMessage[] = [];
const uniqueMarker = `test-${Date.now()}-${Math.random()}`;
const originalOnMessage = adapter.onMessage.bind(adapter);
adapter.onMessage = (message: ClusterMessage) => {
if ((message as any)?.data?.marker === uniqueMarker) {
receivedOwnMessages.push(message);
}
return originalOnMessage(message);
};
const testMessage = {
type: 2,
data: {
marker: uniqueMarker,
opts: { rooms: new Set() },
rooms: [],
},
nsp: '/',
};
void adapter.doPublish(testMessage as any);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(receivedOwnMessages.length).toBe(0);
adapter.close();
});
it('should send and receive response messages between adapters', async () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedResponses: ClusterResponse[] = [];
const responseReceived = new Promise<void>((resolve) => {
const originalOnResponse = adapter1.onResponse.bind(adapter1);
adapter1.onResponse = (response: ClusterResponse) => {
receivedResponses.push(response);
resolve();
return originalOnResponse(response);
};
});
const responseMessage = {
type: 3,
data: { result: 'success', count: 42 },
};
void adapter2.doPublishResponse((adapter1 as any).uid, responseMessage as any);
await Promise.race([responseReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(receivedResponses.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
});
describe('BroadcastChannelAdapter lifecycle', () => {
it('should close cleanly without errors', () => {
const factory = createBroadcastChannelAdapter();
const namespace = createMockNamespace();
const adapter = factory(namespace);
expect(() => adapter.close()).not.toThrow();
});
it('should handle multiple adapters closing in sequence', () => {
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const factory3 = createBroadcastChannelAdapter();
const adapter1 = factory1(createMockNamespace());
const adapter2 = factory2(createMockNamespace());
const adapter3 = factory3(createMockNamespace());
expect(() => {
adapter1.close();
adapter2.close();
adapter3.close();
}).not.toThrow();
});
});
});

View File

@@ -0,0 +1,159 @@
import { Server } from 'socket.io';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { automock } from 'test/utils';
import { vi } from 'vitest';
describe('WebSocket Integration - serverSend with adapters', () => {
describe('BroadcastChannel adapter', () => {
it('should broadcast ConfigUpdate event through BroadcastChannel adapter', async () => {
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: { encode: vi.fn().mockReturnValue([]) },
_opts: {},
sockets: { sockets: new Map() },
},
});
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: any[] = [];
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
receivedMessages.push(message);
});
const configUpdatePayload = {
type: 5,
data: {
event: 'ConfigUpdate',
args: [{ newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } }],
},
nsp: '/',
};
void adapter1.doPublish(configUpdatePayload as any);
await new Promise((resolve) => setTimeout(resolve, 100));
const configMessages = receivedMessages.filter((m) => m?.data?.event === 'ConfigUpdate');
expect(configMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
it('should broadcast AppRestart event through BroadcastChannel adapter', async () => {
const createMockNamespace = () => ({
name: '/',
sockets: new Map(),
adapter: null,
server: {
encoder: { encode: vi.fn().mockReturnValue([]) },
_opts: {},
sockets: { sockets: new Map() },
},
});
const factory1 = createBroadcastChannelAdapter();
const factory2 = createBroadcastChannelAdapter();
const namespace1 = createMockNamespace();
const namespace2 = createMockNamespace();
const adapter1 = factory1(namespace1);
const adapter2 = factory2(namespace2);
await new Promise((resolve) => setTimeout(resolve, 100));
const receivedMessages: any[] = [];
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
receivedMessages.push(message);
});
const appRestartPayload = {
type: 5,
data: {
event: 'AppRestart',
args: [{ isMaintenanceMode: true }],
},
nsp: '/',
};
void adapter1.doPublish(appRestartPayload as any);
await new Promise((resolve) => setTimeout(resolve, 100));
const restartMessages = receivedMessages.filter((m) => m?.data?.event === 'AppRestart');
expect(restartMessages.length).toBeGreaterThan(0);
adapter1.close();
adapter2.close();
});
});
describe('WebsocketRepository with adapter', () => {
it('should call serverSideEmit when serverSend is called', () => {
const mockServer = {
serverSideEmit: vi.fn(),
on: vi.fn(),
} as unknown as Server;
const eventRepository = automock(EventRepository, {
args: [undefined, undefined, { setContext: () => {} }],
});
const loggingRepository = automock(LoggingRepository, {
args: [undefined, { getEnv: () => ({ noColor: false }) }],
strict: false,
});
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
(websocketRepository as any).server = mockServer;
websocketRepository.serverSend('ConfigUpdate', {
newConfig: { ffmpeg: { crf: 23 } } as any,
oldConfig: { ffmpeg: { crf: 20 } } as any,
});
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('ConfigUpdate', {
newConfig: { ffmpeg: { crf: 23 } },
oldConfig: { ffmpeg: { crf: 20 } },
});
});
it('should call serverSideEmit for AppRestart event', () => {
const mockServer = {
serverSideEmit: vi.fn(),
on: vi.fn(),
} as unknown as Server;
const eventRepository = automock(EventRepository, {
args: [undefined, undefined, { setContext: () => {} }],
});
const loggingRepository = automock(LoggingRepository, {
args: [undefined, { getEnv: () => ({ noColor: false }) }],
strict: false,
});
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
(websocketRepository as any).server = mockServer;
websocketRepository.serverSend('AppRestart', { isMaintenanceMode: true });
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('AppRestart', { isMaintenanceMode: true });
});
});
});

View File

@@ -0,0 +1,70 @@
import { INestApplication } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { SocketIoAdapter } from 'src/enum';
import { asPgPoolSsl, createWebSocketAdapter } from 'src/middleware/websocket.adapter';
import { Mocked, vi } from 'vitest';
describe('asPgPoolSsl', () => {
it('should return false for undefined ssl', () => {
expect(asPgPoolSsl()).toBe(false);
});
it('should return false for ssl = false', () => {
expect(asPgPoolSsl(false)).toBe(false);
});
it('should return false for ssl = "allow"', () => {
expect(asPgPoolSsl('allow')).toBe(false);
});
it('should return { rejectUnauthorized: false } for ssl = true', () => {
expect(asPgPoolSsl(true)).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: false } for ssl = "prefer"', () => {
expect(asPgPoolSsl('prefer')).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: false } for ssl = "require"', () => {
expect(asPgPoolSsl('require')).toEqual({ rejectUnauthorized: false });
});
it('should return { rejectUnauthorized: true } for ssl = "verify-full"', () => {
expect(asPgPoolSsl('verify-full')).toEqual({ rejectUnauthorized: true });
});
it('should pass through object ssl config unchanged', () => {
const sslConfig = { ca: 'certificate', rejectUnauthorized: true };
expect(asPgPoolSsl(sslConfig)).toBe(sslConfig);
});
});
describe('createWebSocketAdapter', () => {
let mockApp: Mocked<INestApplication>;
beforeEach(() => {
vi.clearAllMocks();
mockApp = {
getHttpServer: vi.fn().mockReturnValue({}),
} as unknown as Mocked<INestApplication>;
});
describe('BroadcastChannel adapter', () => {
it('should create BroadcastChannel adapter when configured', async () => {
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.BroadcastChannel);
expect(adapter).toBeDefined();
expect(adapter).toBeInstanceOf(IoAdapter);
});
});
describe('Postgres adapter', () => {
it('should create Postgres adapter when configured', async () => {
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.Postgres);
expect(adapter).toBeDefined();
expect(adapter).toBeInstanceOf(IoAdapter);
});
});
});

View File

@@ -1,4 +1,4 @@
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat } from 'src/enum';
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat, SocketIoAdapter } from 'src/enum';
import { ConfigRepository, EnvData } from 'src/repositories/config.repository';
import { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
@@ -9,6 +9,13 @@ const envData: EnvData = {
logFormat: LogFormat.Console,
buildMetadata: {},
bull: {
config: {
connection: {},
prefix: 'immich_bull',
},
queues: [{ name: 'queue-1' }],
},
cls: {
config: {},
@@ -92,6 +99,10 @@ const envData: EnvData = {
},
},
socketIo: {
adapter: SocketIoAdapter.Postgres,
},
noColor: false,
};

View File

@@ -12,13 +12,13 @@ export const newJobRepositoryMock = (): Mocked<RepositoryInterface<JobRepository
pause: vitest.fn(),
resume: vitest.fn(),
searchJobs: vitest.fn(),
queue: vitest.fn(),
queueAll: vitest.fn(),
queue: vitest.fn().mockImplementation(() => Promise.resolve()),
queueAll: vitest.fn().mockImplementation(() => Promise.resolve()),
isActive: vitest.fn(),
isPaused: vitest.fn(),
getJobCounts: vitest.fn(),
clear: vitest.fn(),
waitForQueueCompletion: vitest.fn(),
onShutdown: vitest.fn(),
removeJob: vitest.fn(),
};
};

View File

@@ -226,7 +226,7 @@
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
: slideshowLookCssMapping[$slideshowLook]} checkerboard"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
@@ -259,4 +259,8 @@
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
.checkerboard {
background-image: conic-gradient(#808080 25%, #b0b0b0 25% 50%, #808080 50% 75%, #b0b0b0 75%);
background-size: 20px 20px;
}
</style>

View File

@@ -443,54 +443,50 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
const startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
const endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
if (!startBucket || !endBucket) {
return;
}
const monthGroups = timelineManager.months;
const startBucketIndex = monthGroups.indexOf(startBucket);
const endBucketIndex = monthGroups.indexOf(endBucket);
if (startBucketIndex === -1 || endBucketIndex === -1) {
return;
}
const rangeStartIndex = Math.min(startBucketIndex, endBucketIndex);
const rangeEndIndex = Math.max(startBucketIndex, endBucketIndex);
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
for (let index = rangeStartIndex + 1; index < rangeEndIndex; index++) {
const monthGroup = monthGroups[index];
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const monthAsset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(monthAsset.id);
} else {
handleSelectAsset(monthAsset);
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
for (let index = rangeStartIndex; index <= rangeEndIndex; index++) {
const monthGroup = monthGroups[index];
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
if (monthGroup === endBucket) {
break;
}
}
}

View File

@@ -340,8 +340,8 @@ export const langs: Lang[] = [
{
name: 'Chinese (Simplified)',
code: 'zh-CN',
weblateCode: 'zh_SIMPLIFIED',
loader: () => import('$i18n/zh_SIMPLIFIED.json'),
weblateCode: 'zh_Hans',
loader: () => import('$i18n/zh_Hans.json'),
},
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
];