mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 11:20:12 +03:00
Compare commits
13 Commits
feat/pg-qu
...
postgres-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f276b543 | ||
|
|
2c9d69865c | ||
|
|
72cefcabaf | ||
|
|
2fb9f84b56 | ||
|
|
434ded92f5 | ||
|
|
bc7a1c838c | ||
|
|
7cb355279e | ||
|
|
ecb09501a5 | ||
|
|
34eb2e1410 | ||
|
|
2d6580acd8 | ||
|
|
9aa3fe82c1 | ||
|
|
66733eb4c0 | ||
|
|
e5156df4f1 |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -26,6 +26,7 @@ The `/api/something` endpoint is now `/api/something-else`
|
|||||||
|
|
||||||
## Checklist:
|
## Checklist:
|
||||||
|
|
||||||
|
- [ ] I have carefully read CONTRIBUTING.md
|
||||||
- [ ] I have performed a self-review of my own code
|
- [ ] I have performed a self-review of my own code
|
||||||
- [ ] I have made corresponding changes to the documentation if applicable
|
- [ ] I have made corresponding changes to the documentation if applicable
|
||||||
- [ ] I have no unrelated changes in the PR.
|
- [ ] I have no unrelated changes in the PR.
|
||||||
|
|||||||
38
.github/workflows/close-llm-pr.yml
vendored
Normal file
38
.github/workflows/close-llm-pr.yml
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -17,7 +17,7 @@ If you are looking for something to work on, there are discussions and issues wi
|
|||||||
|
|
||||||
## Use of generative AI
|
## 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
|
## Feature freezes
|
||||||
|
|
||||||
|
|||||||
@@ -180,18 +180,49 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
}
|
}
|
||||||
|
|
||||||
let multiBar: MultiBar | undefined;
|
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) {
|
if (progress) {
|
||||||
multiBar = new MultiBar(
|
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,
|
Presets.shades_classic,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure we restore cursor on interrupt
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
if (multiBar) {
|
||||||
|
multiBar.stop();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
} else {
|
} 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 hashProgressBar = multiBar?.create(totalSize, 0, {
|
||||||
const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' });
|
message: 'Hashing files ',
|
||||||
|
});
|
||||||
|
const checkProgressBar = multiBar?.create(totalSize, 0, {
|
||||||
|
message: 'Checking for duplicates',
|
||||||
|
});
|
||||||
|
|
||||||
const newFiles: string[] = [];
|
const newFiles: string[] = [];
|
||||||
const duplicates: Asset[] = [];
|
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 },
|
{ concurrency, retry: 3 },
|
||||||
);
|
);
|
||||||
@@ -221,6 +258,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
|
|
||||||
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
|
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
|
||||||
async (filepath: string): Promise<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) };
|
const dto = { id: filepath, checksum: await sha1(filepath) };
|
||||||
|
|
||||||
results.push(dto);
|
results.push(dto);
|
||||||
@@ -231,7 +272,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
void checkBulkUploadQueue.push(batch);
|
void checkBulkUploadQueue.push(batch);
|
||||||
}
|
}
|
||||||
|
|
||||||
hashProgressBar?.increment();
|
hashProgressBar?.increment(stats.size);
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
{ concurrency, retry: 3 },
|
{ concurrency, retry: 3 },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
|
shm_size: 128mb
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
@@ -53,6 +54,7 @@ services:
|
|||||||
POSTGRES_DB: immich
|
POSTGRES_DB: immich
|
||||||
ports:
|
ports:
|
||||||
- 5435:5432
|
- 5435:5432
|
||||||
|
shm_size: 128mb
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||||
interval: 1s
|
interval: 1s
|
||||||
|
|||||||
12
i18n/ca.json
12
i18n/ca.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Cercar treballs…",
|
"search_jobs": "Cercar treballs…",
|
||||||
"send_welcome_email": "Enviar correu electrònic de benvinguda",
|
"send_welcome_email": "Enviar correu electrònic de benvinguda",
|
||||||
"server_external_domain_settings": "Domini extern",
|
"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": "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_public_users_description": "Tots els usuaris (nom i correu electrònic) apareixen a la llista a l'afegir un usuari als àlbums compartits. Si es desactiva, la llista només serà disponible pels usuaris administradors.",
|
||||||
"server_settings": "Configuració del servidor",
|
"server_settings": "Configuració del servidor",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Color",
|
"color": "Color",
|
||||||
"color_theme": "Tema de color",
|
"color_theme": "Tema de color",
|
||||||
"command": "Ordre",
|
"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_deleted": "Comentari esborrat",
|
||||||
"comment_options": "Opcions de comentari",
|
"comment_options": "Opcions de comentari",
|
||||||
"comments_and_likes": "Comentaris i agradaments",
|
"comments_and_likes": "Comentaris i agradaments",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "PERSONES",
|
"exif_bottom_sheet_people": "PERSONES",
|
||||||
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
||||||
"exit_slideshow": "Surt de la presentació de diapositives",
|
"exit_slideshow": "Surt de la presentació de diapositives",
|
||||||
|
"expand": "Ampliar-ho",
|
||||||
"expand_all": "Ampliar-ho tot",
|
"expand_all": "Ampliar-ho tot",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Treball en curs",
|
"experimental_settings_new_asset_list_subtitle": "Treball en curs",
|
||||||
"experimental_settings_new_asset_list_title": "Habilita la graella de fotos experimental",
|
"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",
|
"mobile_app_download_onboarding_note": "Descarregar la App de mòbil fent servir les seguents opcions",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"month": "Mes",
|
"month": "Mes",
|
||||||
"monthly_title_text_date_format": "MMMM y",
|
"monthly_title_text_date_format": "MMMM a",
|
||||||
"more": "Més",
|
"more": "Més",
|
||||||
"move": "Moure",
|
"move": "Moure",
|
||||||
"move_down": "Moure cap avall",
|
"move_down": "Moure cap avall",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "En línia",
|
"online": "En línia",
|
||||||
"only_favorites": "Només preferits",
|
"only_favorites": "Només preferits",
|
||||||
"open": "Obrir",
|
"open": "Obrir",
|
||||||
|
"open_calendar": "Obrir el calendari",
|
||||||
"open_in_map_view": "Obrir a la vista del mapa",
|
"open_in_map_view": "Obrir a la vista del mapa",
|
||||||
"open_in_openstreetmap": "Obre a OpenStreetMap",
|
"open_in_openstreetmap": "Obre a OpenStreetMap",
|
||||||
"open_the_search_filters": "Obriu els filtres de cerca",
|
"open_the_search_filters": "Obriu els filtres de cerca",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Suport",
|
"support": "Suport",
|
||||||
"support_and_feedback": "Suport i comentaris",
|
"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.",
|
"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ó",
|
"swap_merge_direction": "Canvia la direcció d'unió",
|
||||||
"sync": "Sincronitza",
|
"sync": "Sincronitza",
|
||||||
"sync_albums": "Sincronitzar àlbums",
|
"sync_albums": "Sincronitzar àlbums",
|
||||||
|
|||||||
10
i18n/cs.json
10
i18n/cs.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Hledat úlohy…",
|
"search_jobs": "Hledat úlohy…",
|
||||||
"send_welcome_email": "Odeslat uvítací e-mail",
|
"send_welcome_email": "Odeslat uvítací e-mail",
|
||||||
"server_external_domain_settings": "Externí doména",
|
"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": "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_public_users_description": "Všichni uživatelé (jméno a e-mail) jsou uvedeni při přidávání uživatele do sdílených alb. Pokud je tato funkce vypnuta, bude seznam uživatelů dostupný pouze uživatelům z řad správců.",
|
||||||
"server_settings": "Server",
|
"server_settings": "Server",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Barva",
|
"color": "Barva",
|
||||||
"color_theme": "Barevný motiv",
|
"color_theme": "Barevný motiv",
|
||||||
"command": "Příkaz",
|
"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_deleted": "Komentář odstraněn",
|
||||||
"comment_options": "Možnosti komentáře",
|
"comment_options": "Možnosti komentáře",
|
||||||
"comments_and_likes": "Komentáře a lajky",
|
"comments_and_likes": "Komentáře a lajky",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "LIDÉ",
|
"exif_bottom_sheet_people": "LIDÉ",
|
||||||
"exif_bottom_sheet_person_add_person": "Přidat jméno",
|
"exif_bottom_sheet_person_add_person": "Přidat jméno",
|
||||||
"exit_slideshow": "Ukončit prezentaci",
|
"exit_slideshow": "Ukončit prezentaci",
|
||||||
|
"expand": "Rozbalit",
|
||||||
"expand_all": "Rozbalit vše",
|
"expand_all": "Rozbalit vše",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Zpracovávám",
|
"experimental_settings_new_asset_list_subtitle": "Zpracovávám",
|
||||||
"experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií",
|
"experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"only_favorites": "Pouze oblíbené",
|
"only_favorites": "Pouze oblíbené",
|
||||||
"open": "Otevřít",
|
"open": "Otevřít",
|
||||||
|
"open_calendar": "Otevřít kalendář",
|
||||||
"open_in_map_view": "Otevřít v zobrazení mapy",
|
"open_in_map_view": "Otevřít v zobrazení mapy",
|
||||||
"open_in_openstreetmap": "Otevřít v OpenStreetMap",
|
"open_in_openstreetmap": "Otevřít v OpenStreetMap",
|
||||||
"open_the_search_filters": "Otevřít vyhledávací filtry",
|
"open_the_search_filters": "Otevřít vyhledávací filtry",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Podpora",
|
"support": "Podpora",
|
||||||
"support_and_feedback": "Podpora a zpětná vazba",
|
"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ů.",
|
"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í",
|
"swap_merge_direction": "Obrátit směr sloučení",
|
||||||
"sync": "Synchronizovat",
|
"sync": "Synchronizovat",
|
||||||
"sync_albums": "Synchronizovat alba",
|
"sync_albums": "Synchronizovat alba",
|
||||||
|
|||||||
16
i18n/es.json
16
i18n/es.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Buscar trabajos…",
|
"search_jobs": "Buscar trabajos…",
|
||||||
"send_welcome_email": "Enviar correo de bienvenida",
|
"send_welcome_email": "Enviar correo de bienvenida",
|
||||||
"server_external_domain_settings": "Dominio externo",
|
"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": "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_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",
|
"server_settings": "Configuración del servidor",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Color",
|
"color": "Color",
|
||||||
"color_theme": "Color del tema",
|
"color_theme": "Color del tema",
|
||||||
"command": "Comando",
|
"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_deleted": "Comentario borrado",
|
||||||
"comment_options": "Opciones de comentarios",
|
"comment_options": "Opciones de comentarios",
|
||||||
"comments_and_likes": "Comentarios y me gusta",
|
"comments_and_likes": "Comentarios y me gusta",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "PERSONAS",
|
"exif_bottom_sheet_people": "PERSONAS",
|
||||||
"exif_bottom_sheet_person_add_person": "Añadir nombre",
|
"exif_bottom_sheet_person_add_person": "Añadir nombre",
|
||||||
"exit_slideshow": "Salir de la presentación",
|
"exit_slideshow": "Salir de la presentación",
|
||||||
|
"expand": "Expandir",
|
||||||
"expand_all": "Expandir todo",
|
"expand_all": "Expandir todo",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Trabajo en progreso",
|
"experimental_settings_new_asset_list_subtitle": "Trabajo en progreso",
|
||||||
"experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental",
|
"experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "En línea",
|
"online": "En línea",
|
||||||
"only_favorites": "Solo favoritos",
|
"only_favorites": "Solo favoritos",
|
||||||
"open": "Abierto",
|
"open": "Abierto",
|
||||||
|
"open_calendar": "Abrir calendario",
|
||||||
"open_in_map_view": "Abrir en la vista del mapa",
|
"open_in_map_view": "Abrir en la vista del mapa",
|
||||||
"open_in_openstreetmap": "Abrir en OpenStreetMap",
|
"open_in_openstreetmap": "Abrir en OpenStreetMap",
|
||||||
"open_the_search_filters": "Abre los filtros de búsqueda",
|
"open_the_search_filters": "Abre los filtros de búsqueda",
|
||||||
@@ -1764,7 +1771,7 @@
|
|||||||
"profile_picture_set": "Conjunto de imágenes de perfil.",
|
"profile_picture_set": "Conjunto de imágenes de perfil.",
|
||||||
"public_album": "Álbum público",
|
"public_album": "Álbum público",
|
||||||
"public_share": "Compartir públicamente",
|
"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_subtitle": "Gracias por apoyar a Immich y al software de código abierto",
|
||||||
"purchase_activated_time": "Activado el {date}",
|
"purchase_activated_time": "Activado el {date}",
|
||||||
"purchase_activated_title": "Su clave ha sido activada correctamente",
|
"purchase_activated_title": "Su clave ha sido activada correctamente",
|
||||||
@@ -1777,7 +1784,7 @@
|
|||||||
"purchase_button_select": "Seleccionar",
|
"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_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_1": "Para un usuario",
|
||||||
"purchase_individual_description_2": "Estado de soporte",
|
"purchase_individual_description_2": "Estatus de colaborador",
|
||||||
"purchase_individual_title": "Individual",
|
"purchase_individual_title": "Individual",
|
||||||
"purchase_input_suggestion": "¿Tiene una clave de producto? Introdúzcala a continuación",
|
"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",
|
"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": "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_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_1": "Para todo el servidor",
|
||||||
"purchase_server_description_2": "Estado del soporte",
|
"purchase_server_description_2": "Estatus de colaborador",
|
||||||
"purchase_server_title": "Servidor",
|
"purchase_server_title": "Servidor",
|
||||||
"purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador",
|
"purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador",
|
||||||
"query_asset_id": "Consultar ID de recurso",
|
"query_asset_id": "Consultar ID de recurso",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Soporte",
|
"support": "Soporte",
|
||||||
"support_and_feedback": "Soporte y comentarios",
|
"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.",
|
"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",
|
"swap_merge_direction": "Alternar dirección de mezcla",
|
||||||
"sync": "Sincronizar",
|
"sync": "Sincronizar",
|
||||||
"sync_albums": "Sincronizar álbumes",
|
"sync_albums": "Sincronizar álbumes",
|
||||||
|
|||||||
10
i18n/et.json
10
i18n/et.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Otsi töödet…",
|
"search_jobs": "Otsi töödet…",
|
||||||
"send_welcome_email": "Saada tervituskiri",
|
"send_welcome_email": "Saada tervituskiri",
|
||||||
"server_external_domain_settings": "Väline domeen",
|
"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": "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_public_users_description": "Kasutaja jagatud albumisse lisamisel kuvatakse kõiki kasutajaid (nime ja e-posti aadressiga). Kui keelatud, kuvatakse kasutajate nimekirja ainult administraatoritele.",
|
||||||
"server_settings": "Serveri seaded",
|
"server_settings": "Serveri seaded",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Värv",
|
"color": "Värv",
|
||||||
"color_theme": "Värviteema",
|
"color_theme": "Värviteema",
|
||||||
"command": "Käsk",
|
"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_deleted": "Kommentaar kustutatud",
|
||||||
"comment_options": "Kommentaari valikud",
|
"comment_options": "Kommentaari valikud",
|
||||||
"comments_and_likes": "Kommentaarid ja meeldimised",
|
"comments_and_likes": "Kommentaarid ja meeldimised",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "ISIKUD",
|
"exif_bottom_sheet_people": "ISIKUD",
|
||||||
"exif_bottom_sheet_person_add_person": "Lisa nimi",
|
"exif_bottom_sheet_person_add_person": "Lisa nimi",
|
||||||
"exit_slideshow": "Sulge slaidiesitlus",
|
"exit_slideshow": "Sulge slaidiesitlus",
|
||||||
|
"expand": "Laienda",
|
||||||
"expand_all": "Näita kõik",
|
"expand_all": "Näita kõik",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Töös",
|
"experimental_settings_new_asset_list_subtitle": "Töös",
|
||||||
"experimental_settings_new_asset_list_title": "Luba eksperimentaalne fotoruudistik",
|
"experimental_settings_new_asset_list_title": "Luba eksperimentaalne fotoruudistik",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Ühendatud",
|
"online": "Ühendatud",
|
||||||
"only_favorites": "Ainult lemmikud",
|
"only_favorites": "Ainult lemmikud",
|
||||||
"open": "Ava",
|
"open": "Ava",
|
||||||
|
"open_calendar": "Ava kalender",
|
||||||
"open_in_map_view": "Ava kaardi vaates",
|
"open_in_map_view": "Ava kaardi vaates",
|
||||||
"open_in_openstreetmap": "Ava OpenStreetMap",
|
"open_in_openstreetmap": "Ava OpenStreetMap",
|
||||||
"open_the_search_filters": "Ava otsingufiltrid",
|
"open_the_search_filters": "Ava otsingufiltrid",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Tugi",
|
"support": "Tugi",
|
||||||
"support_and_feedback": "Tugi ja tagasiside",
|
"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.",
|
"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",
|
"swap_merge_direction": "Muuda ühendamise suunda",
|
||||||
"sync": "Sünkrooni",
|
"sync": "Sünkrooni",
|
||||||
"sync_albums": "Sünkrooni albumid",
|
"sync_albums": "Sünkrooni albumid",
|
||||||
|
|||||||
10
i18n/fr.json
10
i18n/fr.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Recherche des tâches…",
|
"search_jobs": "Recherche des tâches…",
|
||||||
"send_welcome_email": "Envoyer un courriel de bienvenue",
|
"send_welcome_email": "Envoyer un courriel de bienvenue",
|
||||||
"server_external_domain_settings": "Domaine externe",
|
"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": "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_public_users_description": "Tous les utilisateurs (nom et courriel) sont listés lors de l'ajout d'un utilisateur à des albums partagés. Quand cela est désactivé, la liste des utilisateurs est uniquement disponible pour les comptes administrateurs.",
|
||||||
"server_settings": "Paramètres du serveur",
|
"server_settings": "Paramètres du serveur",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Couleur",
|
"color": "Couleur",
|
||||||
"color_theme": "Thème de couleur",
|
"color_theme": "Thème de couleur",
|
||||||
"command": "Commande",
|
"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_deleted": "Commentaire supprimé",
|
||||||
"comment_options": "Options des commentaires",
|
"comment_options": "Options des commentaires",
|
||||||
"comments_and_likes": "Commentaires et \"J'aime\"",
|
"comments_and_likes": "Commentaires et \"J'aime\"",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "PERSONNES",
|
"exif_bottom_sheet_people": "PERSONNES",
|
||||||
"exif_bottom_sheet_person_add_person": "Ajouter un nom",
|
"exif_bottom_sheet_person_add_person": "Ajouter un nom",
|
||||||
"exit_slideshow": "Quitter le diaporama",
|
"exit_slideshow": "Quitter le diaporama",
|
||||||
|
"expand": "Développer",
|
||||||
"expand_all": "Tout développer",
|
"expand_all": "Tout développer",
|
||||||
"experimental_settings_new_asset_list_subtitle": "En cours de développement",
|
"experimental_settings_new_asset_list_subtitle": "En cours de développement",
|
||||||
"experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale",
|
"experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
"only_favorites": "Uniquement les favoris",
|
"only_favorites": "Uniquement les favoris",
|
||||||
"open": "Ouvrir",
|
"open": "Ouvrir",
|
||||||
|
"open_calendar": "Ouvrir le calendrier",
|
||||||
"open_in_map_view": "Montrer sur la carte",
|
"open_in_map_view": "Montrer sur la carte",
|
||||||
"open_in_openstreetmap": "Ouvrir dans OpenStreetMap",
|
"open_in_openstreetmap": "Ouvrir dans OpenStreetMap",
|
||||||
"open_the_search_filters": "Ouvrir les filtres de recherche",
|
"open_the_search_filters": "Ouvrir les filtres de recherche",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Soutenir",
|
"support": "Soutenir",
|
||||||
"support_and_feedback": "Support & Retours",
|
"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.",
|
"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",
|
"swap_merge_direction": "Inverser la direction de fusion",
|
||||||
"sync": "Synchroniser",
|
"sync": "Synchroniser",
|
||||||
"sync_albums": "Synchroniser dans des albums",
|
"sync_albums": "Synchroniser dans des albums",
|
||||||
|
|||||||
10
i18n/ga.json
10
i18n/ga.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Cuardaigh poist…",
|
"search_jobs": "Cuardaigh poist…",
|
||||||
"send_welcome_email": "Seol ríomhphost fáilte",
|
"send_welcome_email": "Seol ríomhphost fáilte",
|
||||||
"server_external_domain_settings": "Fearann seachtrach",
|
"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": "Ú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_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í",
|
"server_settings": "Socruithe Freastalaí",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Dath",
|
"color": "Dath",
|
||||||
"color_theme": "Téama datha",
|
"color_theme": "Téama datha",
|
||||||
"command": "Ordú",
|
"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_deleted": "Trácht scriosta",
|
||||||
"comment_options": "Roghanna tráchta",
|
"comment_options": "Roghanna tráchta",
|
||||||
"comments_and_likes": "Tráchtanna & Is maith liom",
|
"comments_and_likes": "Tráchtanna & Is maith liom",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "DAOINE",
|
"exif_bottom_sheet_people": "DAOINE",
|
||||||
"exif_bottom_sheet_person_add_person": "Cuir ainm leis",
|
"exif_bottom_sheet_person_add_person": "Cuir ainm leis",
|
||||||
"exit_slideshow": "Scoir an Taispeántais Sleamhnán",
|
"exit_slideshow": "Scoir an Taispeántais Sleamhnán",
|
||||||
|
"expand": "Leathnaigh",
|
||||||
"expand_all": "Leathnaigh gach rud",
|
"expand_all": "Leathnaigh gach rud",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Obair ar siúl",
|
"experimental_settings_new_asset_list_subtitle": "Obair ar siúl",
|
||||||
"experimental_settings_new_asset_list_title": "Cumasaigh eangach grianghraf turgnamhach",
|
"experimental_settings_new_asset_list_title": "Cumasaigh eangach grianghraf turgnamhach",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Ar líne",
|
"online": "Ar líne",
|
||||||
"only_favorites": "Is fearr leat amháin",
|
"only_favorites": "Is fearr leat amháin",
|
||||||
"open": "Oscail",
|
"open": "Oscail",
|
||||||
|
"open_calendar": "Oscail an féilire",
|
||||||
"open_in_map_view": "Oscail i radharc léarscáile",
|
"open_in_map_view": "Oscail i radharc léarscáile",
|
||||||
"open_in_openstreetmap": "Oscail in OpenStreetMap",
|
"open_in_openstreetmap": "Oscail in OpenStreetMap",
|
||||||
"open_the_search_filters": "Oscail na scagairí cuardaigh",
|
"open_the_search_filters": "Oscail na scagairí cuardaigh",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Tacaíocht",
|
"support": "Tacaíocht",
|
||||||
"support_and_feedback": "Tacaíocht & Aiseolas",
|
"support_and_feedback": "Tacaíocht & Aiseolas",
|
||||||
"support_third_party_description": "Rinne tríú páirtí pacáiste de do shuiteáil Immich. D’fhé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.",
|
"support_third_party_description": "Rinne tríú páirtí pacáiste de do shuiteáil Immich. D’fhé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",
|
"swap_merge_direction": "Malartaigh treo an chumaisc",
|
||||||
"sync": "Sioncrónaigh",
|
"sync": "Sioncrónaigh",
|
||||||
"sync_albums": "Sioncrónaigh albaim",
|
"sync_albums": "Sioncrónaigh albaim",
|
||||||
|
|||||||
10
i18n/it.json
10
i18n/it.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Cerca Attività…",
|
"search_jobs": "Cerca Attività…",
|
||||||
"send_welcome_email": "Invia email di benvenuto",
|
"send_welcome_email": "Invia email di benvenuto",
|
||||||
"server_external_domain_settings": "Dominio esterno",
|
"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": "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_public_users_description": "Tutti gli utenti (nome ed e-mail) sono elencati quando si aggiunge un utente agli album condivisi. Quando disabilitato, l'elenco degli utenti sarà disponibile solo per gli utenti amministratori.",
|
||||||
"server_settings": "Impostazioni Server",
|
"server_settings": "Impostazioni Server",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Colore",
|
"color": "Colore",
|
||||||
"color_theme": "Colore Tema",
|
"color_theme": "Colore Tema",
|
||||||
"command": "Comando",
|
"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_deleted": "Commento eliminato",
|
||||||
"comment_options": "Opzioni per i commenti",
|
"comment_options": "Opzioni per i commenti",
|
||||||
"comments_and_likes": "Commenti & mi piace",
|
"comments_and_likes": "Commenti & mi piace",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "PERSONE",
|
"exif_bottom_sheet_people": "PERSONE",
|
||||||
"exif_bottom_sheet_person_add_person": "Aggiungi nome",
|
"exif_bottom_sheet_person_add_person": "Aggiungi nome",
|
||||||
"exit_slideshow": "Esci dalla presentazione",
|
"exit_slideshow": "Esci dalla presentazione",
|
||||||
|
"expand": "Espandi",
|
||||||
"expand_all": "Espandi tutto",
|
"expand_all": "Espandi tutto",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Lavori in corso",
|
"experimental_settings_new_asset_list_subtitle": "Lavori in corso",
|
||||||
"experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale",
|
"experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"only_favorites": "Solo preferiti",
|
"only_favorites": "Solo preferiti",
|
||||||
"open": "Apri",
|
"open": "Apri",
|
||||||
|
"open_calendar": "Apri il calendario",
|
||||||
"open_in_map_view": "Apri nella visualizzazione mappa",
|
"open_in_map_view": "Apri nella visualizzazione mappa",
|
||||||
"open_in_openstreetmap": "Apri su OpenStreetMap",
|
"open_in_openstreetmap": "Apri su OpenStreetMap",
|
||||||
"open_the_search_filters": "Apri filtri di ricerca",
|
"open_the_search_filters": "Apri filtri di ricerca",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Supporto",
|
"support": "Supporto",
|
||||||
"support_and_feedback": "Supporto & Feedback",
|
"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.",
|
"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",
|
"swap_merge_direction": "Scambia direzione di unione",
|
||||||
"sync": "Sincronizza",
|
"sync": "Sincronizza",
|
||||||
"sync_albums": "Sincronizza album",
|
"sync_albums": "Sincronizza album",
|
||||||
|
|||||||
14
i18n/nl.json
14
i18n/nl.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Taak zoeken…",
|
"search_jobs": "Taak zoeken…",
|
||||||
"send_welcome_email": "Stuur een welkomstmail",
|
"send_welcome_email": "Stuur een welkomstmail",
|
||||||
"server_external_domain_settings": "Extern domein",
|
"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": "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_public_users_description": "Alle gebruikers (met naam en e-mailadres) worden weergegeven wanneer een gebruiker wordt toegevoegd aan gedeelde albums. Wanneer uitgeschakeld, is de gebruikerslijst alleen beschikbaar voor beheerders.",
|
||||||
"server_settings": "Serverinstellingen",
|
"server_settings": "Serverinstellingen",
|
||||||
@@ -793,7 +793,12 @@
|
|||||||
"collapse_all": "Alles inklappen",
|
"collapse_all": "Alles inklappen",
|
||||||
"color": "Kleur",
|
"color": "Kleur",
|
||||||
"color_theme": "Kleurenthema",
|
"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_deleted": "Opmerking verwijderd",
|
||||||
"comment_options": "Opties voor opmerkingen",
|
"comment_options": "Opties voor opmerkingen",
|
||||||
"comments_and_likes": "Opmerkingen & likes",
|
"comments_and_likes": "Opmerkingen & likes",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "MENSEN",
|
"exif_bottom_sheet_people": "MENSEN",
|
||||||
"exif_bottom_sheet_person_add_person": "Naam toevoegen",
|
"exif_bottom_sheet_person_add_person": "Naam toevoegen",
|
||||||
"exit_slideshow": "Diavoorstelling sluiten",
|
"exit_slideshow": "Diavoorstelling sluiten",
|
||||||
|
"expand": "Uitklappen",
|
||||||
"expand_all": "Alles uitvouwen",
|
"expand_all": "Alles uitvouwen",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Werk in uitvoering",
|
"experimental_settings_new_asset_list_subtitle": "Werk in uitvoering",
|
||||||
"experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen",
|
"experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"only_favorites": "Alleen favorieten",
|
"only_favorites": "Alleen favorieten",
|
||||||
"open": "Openen",
|
"open": "Openen",
|
||||||
|
"open_calendar": "Open kalender",
|
||||||
"open_in_map_view": "Openen in kaartweergave",
|
"open_in_map_view": "Openen in kaartweergave",
|
||||||
"open_in_openstreetmap": "Openen in OpenStreetMap",
|
"open_in_openstreetmap": "Openen in OpenStreetMap",
|
||||||
"open_the_search_filters": "Open de zoekfilters",
|
"open_the_search_filters": "Open de zoekfilters",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Ondersteuning",
|
"support": "Ondersteuning",
|
||||||
"support_and_feedback": "Ondersteuning & feedback",
|
"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.",
|
"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",
|
"swap_merge_direction": "Wissel richting voor samenvoegen om",
|
||||||
"sync": "Synchroniseren",
|
"sync": "Synchroniseren",
|
||||||
"sync_albums": "Albums synchroniseren",
|
"sync_albums": "Albums synchroniseren",
|
||||||
@@ -2294,7 +2302,7 @@
|
|||||||
"unstack_action_prompt": "{count} item(s) ontstapeld",
|
"unstack_action_prompt": "{count} item(s) ontstapeld",
|
||||||
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapeld",
|
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapeld",
|
||||||
"unsupported_field_type": "Veldtype niet ondersteund",
|
"unsupported_field_type": "Veldtype niet ondersteund",
|
||||||
"untagged": "Ongemarkeerd",
|
"untagged": "Zonder tags",
|
||||||
"untitled_workflow": "Naamloze werkstroom",
|
"untitled_workflow": "Naamloze werkstroom",
|
||||||
"up_next": "Volgende",
|
"up_next": "Volgende",
|
||||||
"update_location_action_prompt": "Werk de locatie bij van {count} geselecteerde items met:",
|
"update_location_action_prompt": "Werk de locatie bij van {count} geselecteerde items met:",
|
||||||
|
|||||||
@@ -2064,7 +2064,7 @@
|
|||||||
"shared_by_you": "Udostępnione przez ciebie",
|
"shared_by_you": "Udostępnione przez ciebie",
|
||||||
"shared_from_partner": "Zdjęcia od {partner}",
|
"shared_from_partner": "Zdjęcia od {partner}",
|
||||||
"shared_intent_upload_button_progress_text": "{current} / {total} Przesłano",
|
"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_copied_massage": "Skopiowane do schowka",
|
||||||
"shared_link_clipboard_text": "Link: {link}\nHasło: {password}",
|
"shared_link_clipboard_text": "Link: {link}\nHasło: {password}",
|
||||||
"shared_link_create_error": "Błąd podczas tworzenia linka do udostępnienia",
|
"shared_link_create_error": "Błąd podczas tworzenia linka do udostępnienia",
|
||||||
|
|||||||
12
i18n/ru.json
12
i18n/ru.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Поиск задач…",
|
"search_jobs": "Поиск задач…",
|
||||||
"send_welcome_email": "Отправить приветственное письмо",
|
"send_welcome_email": "Отправить приветственное письмо",
|
||||||
"server_external_domain_settings": "Внешний домен",
|
"server_external_domain_settings": "Внешний домен",
|
||||||
"server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://",
|
"server_external_domain_settings_description": "Домен для публичных ссылок",
|
||||||
"server_public_users": "Публичные пользователи",
|
"server_public_users": "Публичные пользователи",
|
||||||
"server_public_users_description": "Выводить список пользователей (имена и email) в общих альбомах. Когда отключено, список доступен только администраторам, пользователи смогут делиться только ссылкой.",
|
"server_public_users_description": "Выводить список пользователей (имена и email) в общих альбомах. Когда отключено, список доступен только администраторам, пользователи смогут делиться только ссылкой.",
|
||||||
"server_settings": "Настройки сервера",
|
"server_settings": "Настройки сервера",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Цвет",
|
"color": "Цвет",
|
||||||
"color_theme": "Цветовая тема",
|
"color_theme": "Цветовая тема",
|
||||||
"command": "Команда",
|
"command": "Команда",
|
||||||
|
"command_palette_prompt": "Быстрый поиск страниц, действий или команд",
|
||||||
|
"command_palette_to_close": "закрыть",
|
||||||
|
"command_palette_to_navigate": "навигация",
|
||||||
|
"command_palette_to_select": "выбрать",
|
||||||
|
"command_palette_to_show_all": "показать все",
|
||||||
"comment_deleted": "Комментарий удалён",
|
"comment_deleted": "Комментарий удалён",
|
||||||
"comment_options": "Действия с комментарием",
|
"comment_options": "Действия с комментарием",
|
||||||
"comments_and_likes": "Комментарии и отметки \"нравится\"",
|
"comments_and_likes": "Комментарии и отметки \"нравится\"",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "ЛЮДИ",
|
"exif_bottom_sheet_people": "ЛЮДИ",
|
||||||
"exif_bottom_sheet_person_add_person": "Добавить имя",
|
"exif_bottom_sheet_person_add_person": "Добавить имя",
|
||||||
"exit_slideshow": "Выйти из слайд-шоу",
|
"exit_slideshow": "Выйти из слайд-шоу",
|
||||||
|
"expand": "Развернуть",
|
||||||
"expand_all": "Развернуть всё",
|
"expand_all": "Развернуть всё",
|
||||||
"experimental_settings_new_asset_list_subtitle": "В разработке",
|
"experimental_settings_new_asset_list_subtitle": "В разработке",
|
||||||
"experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий",
|
"experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Доступен",
|
"online": "Доступен",
|
||||||
"only_favorites": "Только избранное",
|
"only_favorites": "Только избранное",
|
||||||
"open": "Открыть",
|
"open": "Открыть",
|
||||||
|
"open_calendar": "Открыть календарь",
|
||||||
"open_in_map_view": "Открыть в режиме просмотра карты",
|
"open_in_map_view": "Открыть в режиме просмотра карты",
|
||||||
"open_in_openstreetmap": "Открыть в OpenStreetMap",
|
"open_in_openstreetmap": "Открыть в OpenStreetMap",
|
||||||
"open_the_search_filters": "Открыть фильтры поиска",
|
"open_the_search_filters": "Открыть фильтры поиска",
|
||||||
@@ -2128,7 +2135,7 @@
|
|||||||
"show_search_options": "Показать параметры поиска",
|
"show_search_options": "Показать параметры поиска",
|
||||||
"show_shared_links": "Показать публичные ссылки",
|
"show_shared_links": "Показать публичные ссылки",
|
||||||
"show_slideshow_transition": "Плавный переход",
|
"show_slideshow_transition": "Плавный переход",
|
||||||
"show_supporter_badge": "Значок поддержки",
|
"show_supporter_badge": "Значок спонсорства",
|
||||||
"show_supporter_badge_description": "Показать значок поддержки",
|
"show_supporter_badge_description": "Показать значок поддержки",
|
||||||
"show_text_recognition": "Показать распознанный текст",
|
"show_text_recognition": "Показать распознанный текст",
|
||||||
"show_text_search_menu": "Показать меню текстового поиска",
|
"show_text_search_menu": "Показать меню текстового поиска",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Поддержка",
|
"support": "Поддержка",
|
||||||
"support_and_feedback": "Поддержка и обратная связь",
|
"support_and_feedback": "Поддержка и обратная связь",
|
||||||
"support_third_party_description": "Ваша установка immich была упакована сторонним разработчиком. Проблемы, с которыми вы столкнулись, могут быть вызваны этим пакетом, поэтому, пожалуйста, в первую очередь обращайтесь к ним, используя ссылки ниже.",
|
"support_third_party_description": "Ваша установка immich была упакована сторонним разработчиком. Проблемы, с которыми вы столкнулись, могут быть вызваны этим пакетом, поэтому, пожалуйста, в первую очередь обращайтесь к ним, используя ссылки ниже.",
|
||||||
|
"supporter": "Спонсор Immich",
|
||||||
"swap_merge_direction": "Изменить направление слияния",
|
"swap_merge_direction": "Изменить направление слияния",
|
||||||
"sync": "Синхр.",
|
"sync": "Синхр.",
|
||||||
"sync_albums": "Синхронизировать альбомы",
|
"sync_albums": "Синхронизировать альбомы",
|
||||||
|
|||||||
10
i18n/sk.json
10
i18n/sk.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Vyhľadať úlohy…",
|
"search_jobs": "Vyhľadať úlohy…",
|
||||||
"send_welcome_email": "Odoslať uvítací e-mail",
|
"send_welcome_email": "Odoslať uvítací e-mail",
|
||||||
"server_external_domain_settings": "Externá doména",
|
"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": "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_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",
|
"server_settings": "Server",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Farba",
|
"color": "Farba",
|
||||||
"color_theme": "Farba témy",
|
"color_theme": "Farba témy",
|
||||||
"command": "Príkaz",
|
"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_deleted": "Komentár bol odstránený",
|
||||||
"comment_options": "Možnosti komentára",
|
"comment_options": "Možnosti komentára",
|
||||||
"comments_and_likes": "Komentáre a páči sa mi to",
|
"comments_and_likes": "Komentáre a páči sa mi to",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "ĽUDIA",
|
"exif_bottom_sheet_people": "ĽUDIA",
|
||||||
"exif_bottom_sheet_person_add_person": "Pridať meno",
|
"exif_bottom_sheet_person_add_person": "Pridať meno",
|
||||||
"exit_slideshow": "Opustiť prezentáciu",
|
"exit_slideshow": "Opustiť prezentáciu",
|
||||||
|
"expand": "Rozbaliť",
|
||||||
"expand_all": "Rozbaliť všetko",
|
"expand_all": "Rozbaliť všetko",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Prebiehajúca práca",
|
"experimental_settings_new_asset_list_subtitle": "Prebiehajúca práca",
|
||||||
"experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií",
|
"experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"only_favorites": "Len obľúbené",
|
"only_favorites": "Len obľúbené",
|
||||||
"open": "Otvoriť",
|
"open": "Otvoriť",
|
||||||
|
"open_calendar": "Otvoriť kalendár",
|
||||||
"open_in_map_view": "Otvoriť v mape",
|
"open_in_map_view": "Otvoriť v mape",
|
||||||
"open_in_openstreetmap": "Otvoriť v OpenStreetMap",
|
"open_in_openstreetmap": "Otvoriť v OpenStreetMap",
|
||||||
"open_the_search_filters": "Otvoriť vyhľadávacie filtre",
|
"open_the_search_filters": "Otvoriť vyhľadávacie filtre",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Podpora",
|
"support": "Podpora",
|
||||||
"support_and_feedback": "Podpora a spätná väzba",
|
"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.",
|
"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",
|
"swap_merge_direction": "Vymeniť smer zlúčenia",
|
||||||
"sync": "Synchronizovať",
|
"sync": "Synchronizovať",
|
||||||
"sync_albums": "Synchronizovať albumy",
|
"sync_albums": "Synchronizovať albumy",
|
||||||
|
|||||||
10
i18n/sl.json
10
i18n/sl.json
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "Išči opravila…",
|
"search_jobs": "Išči opravila…",
|
||||||
"send_welcome_email": "Pošlji pozdravno e-pošto",
|
"send_welcome_email": "Pošlji pozdravno e-pošto",
|
||||||
"server_external_domain_settings": "Zunanja domena",
|
"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": "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_public_users_description": "Vsi uporabniki (ime in e-pošta) so navedeni pri dodajanju uporabnika v albume v skupni rabi. Ko je onemogočen, bo seznam uporabnikov na voljo samo skrbniškim uporabnikom.",
|
||||||
"server_settings": "Nastavitve strežnika",
|
"server_settings": "Nastavitve strežnika",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "Barva",
|
"color": "Barva",
|
||||||
"color_theme": "Barva teme",
|
"color_theme": "Barva teme",
|
||||||
"command": "Ukaz",
|
"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_deleted": "Komentar izbrisan",
|
||||||
"comment_options": "Možnosti komentiranja",
|
"comment_options": "Možnosti komentiranja",
|
||||||
"comments_and_likes": "Komentarji in všečki",
|
"comments_and_likes": "Komentarji in všečki",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "OSEBE",
|
"exif_bottom_sheet_people": "OSEBE",
|
||||||
"exif_bottom_sheet_person_add_person": "Dodaj ime",
|
"exif_bottom_sheet_person_add_person": "Dodaj ime",
|
||||||
"exit_slideshow": "Zapustite diaprojekcijo",
|
"exit_slideshow": "Zapustite diaprojekcijo",
|
||||||
|
"expand": "Razširi",
|
||||||
"expand_all": "Razširi vse",
|
"expand_all": "Razširi vse",
|
||||||
"experimental_settings_new_asset_list_subtitle": "Delo v teku",
|
"experimental_settings_new_asset_list_subtitle": "Delo v teku",
|
||||||
"experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij",
|
"experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "Povezano",
|
"online": "Povezano",
|
||||||
"only_favorites": "Samo priljubljene",
|
"only_favorites": "Samo priljubljene",
|
||||||
"open": "Odpri",
|
"open": "Odpri",
|
||||||
|
"open_calendar": "Odpri koledar",
|
||||||
"open_in_map_view": "Odpri v pogledu zemljevida",
|
"open_in_map_view": "Odpri v pogledu zemljevida",
|
||||||
"open_in_openstreetmap": "Odpri v OpenStreetMap",
|
"open_in_openstreetmap": "Odpri v OpenStreetMap",
|
||||||
"open_the_search_filters": "Odpri iskalne filtre",
|
"open_the_search_filters": "Odpri iskalne filtre",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "Podpora",
|
"support": "Podpora",
|
||||||
"support_and_feedback": "Podpora in povratne informacije",
|
"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.",
|
"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",
|
"swap_merge_direction": "Zamenjaj smer združevanja",
|
||||||
"sync": "Sinhronizacija",
|
"sync": "Sinhronizacija",
|
||||||
"sync_albums": "Sinhronizacija albumov",
|
"sync_albums": "Sinhronizacija albumov",
|
||||||
|
|||||||
24
i18n/th.json
24
i18n/th.json
@@ -6,8 +6,8 @@
|
|||||||
"action": "ดำเนินการ",
|
"action": "ดำเนินการ",
|
||||||
"action_common_update": "อัปเดต",
|
"action_common_update": "อัปเดต",
|
||||||
"actions": "การดำเนินการ",
|
"actions": "การดำเนินการ",
|
||||||
"active": "ใช้งานอยู่",
|
"active": "กำลังทำงาน",
|
||||||
"active_count": "ใช้งานอยู่: {count}",
|
"active_count": "กำลังทำงาน: {count}",
|
||||||
"activity": "กิจกรรม",
|
"activity": "กิจกรรม",
|
||||||
"activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่",
|
"activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่",
|
||||||
"add": "เพิ่ม",
|
"add": "เพิ่ม",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"add_path": "เพิ่มพาทที่ตั้ง",
|
"add_path": "เพิ่มพาทที่ตั้ง",
|
||||||
"add_photos": "เพิ่มรูปภาพ",
|
"add_photos": "เพิ่มรูปภาพ",
|
||||||
"add_tag": "เพิ่มแท็ก",
|
"add_tag": "เพิ่มแท็ก",
|
||||||
"add_to": "เพิ่มไปยัง …",
|
"add_to": "เพิ่มไปยัง…",
|
||||||
"add_to_album": "เพิ่มไปยังอัลบั้ม",
|
"add_to_album": "เพิ่มไปยังอัลบั้ม",
|
||||||
"add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album} แล้ว",
|
"add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album} แล้ว",
|
||||||
"add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว",
|
"add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว",
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
"metadata_settings": "การตั้งค่า Metadata",
|
"metadata_settings": "การตั้งค่า Metadata",
|
||||||
"metadata_settings_description": "จัดการการตั้งค่า Metadata",
|
"metadata_settings_description": "จัดการการตั้งค่า Metadata",
|
||||||
"migration_job": "การโยกย้าย",
|
"migration_job": "การโยกย้าย",
|
||||||
"migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
|
"migration_job_description": "ย้ายภาพตัวอย่างสำหรับสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
|
||||||
"nightly_tasks_cluster_new_faces_setting": "คลัสเตอร์ใบหน้าใหม่",
|
"nightly_tasks_cluster_new_faces_setting": "คลัสเตอร์ใบหน้าใหม่",
|
||||||
"nightly_tasks_generate_memories_setting": "สร้างความทรงจำ",
|
"nightly_tasks_generate_memories_setting": "สร้างความทรงจำ",
|
||||||
"nightly_tasks_generate_memories_setting_description": "สร้างความทรงจำใหม่จากสื่อ",
|
"nightly_tasks_generate_memories_setting_description": "สร้างความทรงจำใหม่จากสื่อ",
|
||||||
@@ -526,20 +526,20 @@
|
|||||||
"asset_viewer_settings_subtitle": "ตั้งค่าการแสดงแกลเลอรี",
|
"asset_viewer_settings_subtitle": "ตั้งค่าการแสดงแกลเลอรี",
|
||||||
"asset_viewer_settings_title": "ตัวดูทรัพยากร",
|
"asset_viewer_settings_title": "ตัวดูทรัพยากร",
|
||||||
"assets": "สื่อ",
|
"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_album_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยังอัลบั้ม",
|
||||||
"assets_added_to_albums_count": "เพิ่มสื่อ {assetTotal, plural, one {# รายการ} other {# รายการ}} ไปยังอัลบั้ม {albumTotal, plural, one {# รายการ} other {# รายการ}}แล้ว",
|
"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_album_count": "ไม่สามารถเพิ่ม {count, plural, one {สื่อ} other {สื่อ}} ไปยังอัลบั้ม",
|
||||||
"assets_cannot_be_added_to_albums": "ไม่สามารถเพิ่ม{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": "{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_failed": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} ไม่สำเร็จ - {error}",
|
||||||
"assets_downloaded_successfully": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} สำเร็จ",
|
"assets_downloaded_successfully": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} สำเร็จ",
|
||||||
"assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว",
|
"assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว",
|
||||||
"assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร",
|
"assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร",
|
||||||
"assets_removed_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_restore_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนสื่อที่ทิ้งทั้งหมด? คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้! โปรดทราบว่าสื่อออฟไลน์ใดๆ ไม่สามารถกู้คืนได้ด้วยวิธีนี้",
|
||||||
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า",
|
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า",
|
||||||
"assets_restored_successfully": "กู้คืน {count} สื่อสำเร็จ",
|
"assets_restored_successfully": "กู้คืน {count} สื่อสำเร็จ",
|
||||||
@@ -699,6 +699,8 @@
|
|||||||
"cleanup_found_assets": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว",
|
"cleanup_found_assets": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว",
|
||||||
"cleanup_found_assets_with_size": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว ({size})",
|
"cleanup_found_assets_with_size": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว ({size})",
|
||||||
"cleanup_icloud_shared_albums_excluded": "อัลบั้มที่แชร์บน iCloud ไม่นับรวมในการค้นหา",
|
"cleanup_icloud_shared_albums_excluded": "อัลบั้มที่แชร์บน iCloud ไม่นับรวมในการค้นหา",
|
||||||
|
"cleanup_no_assets_found": "ไม่พบสื่อที่ตรงตามเงื่อนไขด้านบน \"เพิ่มพื้นที่ว่าง\" สามารถลบได้เฉพาะสื่อที่สำรองข้อมูลบนเซิร์ฟเวอร์เรียบร้อยแล้วเท่านั้น",
|
||||||
|
"cleanup_preview_title": "สื่อที่จะลบ ({count})",
|
||||||
"clear": "ล้าง",
|
"clear": "ล้าง",
|
||||||
"clear_all": "ล้างทั้งหมด",
|
"clear_all": "ล้างทั้งหมด",
|
||||||
"clear_all_recent_searches": "ล้างประวัติการค้นหา",
|
"clear_all_recent_searches": "ล้างประวัติการค้นหา",
|
||||||
@@ -1062,7 +1064,7 @@
|
|||||||
"export_as_json": "ส่งออกเป็นไฟล์ JSON",
|
"export_as_json": "ส่งออกเป็นไฟล์ JSON",
|
||||||
"extension": "ส่วนต่อขยาย",
|
"extension": "ส่วนต่อขยาย",
|
||||||
"external": "ภายนอก",
|
"external": "ภายนอก",
|
||||||
"external_libraries": "ภายนอกคลังภาพ",
|
"external_libraries": "คลังภาพภายนอก",
|
||||||
"external_network": "การเชื่อมต่อภายนอก",
|
"external_network": "การเชื่อมต่อภายนอก",
|
||||||
"external_network_sheet_info": "เมื่อไม่ได้เชื่อมต่อ Wi-Fi ที่เลือกไว้ แอพจะเชื่อมต่อเซิร์ฟเวอร์ผ่าน URL ด้านล่างตามลำดับ",
|
"external_network_sheet_info": "เมื่อไม่ได้เชื่อมต่อ Wi-Fi ที่เลือกไว้ แอพจะเชื่อมต่อเซิร์ฟเวอร์ผ่าน URL ด้านล่างตามลำดับ",
|
||||||
"face_unassigned": "ไม่กำหนดมอบหมาย",
|
"face_unassigned": "ไม่กำหนดมอบหมาย",
|
||||||
@@ -1658,8 +1660,8 @@
|
|||||||
"server_endpoint": "ปลายทางเซิร์ฟเวอร์",
|
"server_endpoint": "ปลายทางเซิร์ฟเวอร์",
|
||||||
"server_info_box_app_version": "เวอร์ชันแอพ",
|
"server_info_box_app_version": "เวอร์ชันแอพ",
|
||||||
"server_info_box_server_url": "URL เซิร์ฟเวอร์",
|
"server_info_box_server_url": "URL เซิร์ฟเวอร์",
|
||||||
"server_offline": "Server ออฟไลน์",
|
"server_offline": "เซิร์ฟเวอร์ออฟไลน์",
|
||||||
"server_online": "Server ออนไลน์",
|
"server_online": "เซิร์ฟเวอร์ออนไลน์",
|
||||||
"server_privacy": "ความเป็นส่วนตัวเซิร์ฟเวอร์",
|
"server_privacy": "ความเป็นส่วนตัวเซิร์ฟเวอร์",
|
||||||
"server_stats": "สถิติเซิร์ฟเวอร์",
|
"server_stats": "สถิติเซิร์ฟเวอร์",
|
||||||
"server_version": "เวอร์ชันของเซิร์ฟเวอร์",
|
"server_version": "เวอร์ชันของเซิร์ฟเวอร์",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"add_to_bottom_bar": "加至",
|
"add_to_bottom_bar": "加至",
|
||||||
"add_to_shared_album": "加至共享相簿",
|
"add_to_shared_album": "加至共享相簿",
|
||||||
"add_url": "加網址",
|
"add_url": "加網址",
|
||||||
|
"add_workflow_step": "增加工作步驟",
|
||||||
"added_to_favorites": "已加至最愛",
|
"added_to_favorites": "已加至最愛",
|
||||||
"added_to_favorites_count": "已加{count, number} 個項目至最愛",
|
"added_to_favorites_count": "已加{count, number} 個項目至最愛",
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -311,7 +311,7 @@
|
|||||||
"search_jobs": "搜尋任務…",
|
"search_jobs": "搜尋任務…",
|
||||||
"send_welcome_email": "傳送歡迎電子郵件",
|
"send_welcome_email": "傳送歡迎電子郵件",
|
||||||
"server_external_domain_settings": "外部網域",
|
"server_external_domain_settings": "外部網域",
|
||||||
"server_external_domain_settings_description": "公開分享連結的網域,包含 http(s)://",
|
"server_external_domain_settings_description": "公開分享連結的網域",
|
||||||
"server_public_users": "公開使用者",
|
"server_public_users": "公開使用者",
|
||||||
"server_public_users_description": "將使用者新增至共享相簿時,會列出所有使用者(姓名與電子郵件)。若停用,使用者清單將僅供管理員查看。",
|
"server_public_users_description": "將使用者新增至共享相簿時,會列出所有使用者(姓名與電子郵件)。若停用,使用者清單將僅供管理員查看。",
|
||||||
"server_settings": "伺服器設定",
|
"server_settings": "伺服器設定",
|
||||||
@@ -794,6 +794,11 @@
|
|||||||
"color": "顏色",
|
"color": "顏色",
|
||||||
"color_theme": "色彩主題",
|
"color_theme": "色彩主題",
|
||||||
"command": "命令",
|
"command": "命令",
|
||||||
|
"command_palette_prompt": "快速尋找頁面,動作或者指令",
|
||||||
|
"command_palette_to_close": "關閉",
|
||||||
|
"command_palette_to_navigate": "輸入",
|
||||||
|
"command_palette_to_select": "選擇",
|
||||||
|
"command_palette_to_show_all": "顯示全部",
|
||||||
"comment_deleted": "留言已刪除",
|
"comment_deleted": "留言已刪除",
|
||||||
"comment_options": "留言選項",
|
"comment_options": "留言選項",
|
||||||
"comments_and_likes": "留言與喜歡",
|
"comments_and_likes": "留言與喜歡",
|
||||||
@@ -1168,6 +1173,7 @@
|
|||||||
"exif_bottom_sheet_people": "人物",
|
"exif_bottom_sheet_people": "人物",
|
||||||
"exif_bottom_sheet_person_add_person": "新增姓名",
|
"exif_bottom_sheet_person_add_person": "新增姓名",
|
||||||
"exit_slideshow": "結束幻燈片",
|
"exit_slideshow": "結束幻燈片",
|
||||||
|
"expand": "展開",
|
||||||
"expand_all": "展開全部",
|
"expand_all": "展開全部",
|
||||||
"experimental_settings_new_asset_list_subtitle": "正在處理",
|
"experimental_settings_new_asset_list_subtitle": "正在處理",
|
||||||
"experimental_settings_new_asset_list_title": "啟用實驗性相片格狀版面",
|
"experimental_settings_new_asset_list_title": "啟用實驗性相片格狀版面",
|
||||||
@@ -1642,6 +1648,7 @@
|
|||||||
"online": "線上",
|
"online": "線上",
|
||||||
"only_favorites": "僅顯示己收藏",
|
"only_favorites": "僅顯示己收藏",
|
||||||
"open": "開啟",
|
"open": "開啟",
|
||||||
|
"open_calendar": "打開日曆",
|
||||||
"open_in_map_view": "開啟地圖檢視",
|
"open_in_map_view": "開啟地圖檢視",
|
||||||
"open_in_openstreetmap": "用 OpenStreetMap 開啟",
|
"open_in_openstreetmap": "用 OpenStreetMap 開啟",
|
||||||
"open_the_search_filters": "開啟搜尋篩選器",
|
"open_the_search_filters": "開啟搜尋篩選器",
|
||||||
@@ -1916,7 +1923,7 @@
|
|||||||
"search_by_description_example": "在沙壩的健行之日",
|
"search_by_description_example": "在沙壩的健行之日",
|
||||||
"search_by_filename": "依檔名或副檔名搜尋",
|
"search_by_filename": "依檔名或副檔名搜尋",
|
||||||
"search_by_filename_example": "如 IMG_1234.JPG 或 PNG",
|
"search_by_filename_example": "如 IMG_1234.JPG 或 PNG",
|
||||||
"search_by_ocr": "通過OCR蒐索",
|
"search_by_ocr": "透過OCR搜尋",
|
||||||
"search_by_ocr_example": "拿鐵",
|
"search_by_ocr_example": "拿鐵",
|
||||||
"search_camera_lens_model": "蒐索鏡頭型號...",
|
"search_camera_lens_model": "蒐索鏡頭型號...",
|
||||||
"search_camera_make": "搜尋相機製造商…",
|
"search_camera_make": "搜尋相機製造商…",
|
||||||
@@ -1935,7 +1942,7 @@
|
|||||||
"search_filter_location_title": "選擇位置",
|
"search_filter_location_title": "選擇位置",
|
||||||
"search_filter_media_type": "媒體類型",
|
"search_filter_media_type": "媒體類型",
|
||||||
"search_filter_media_type_title": "選擇媒體類型",
|
"search_filter_media_type_title": "選擇媒體類型",
|
||||||
"search_filter_ocr": "通過OCR蒐索",
|
"search_filter_ocr": "透過OCR搜尋",
|
||||||
"search_filter_people_title": "選擇人物",
|
"search_filter_people_title": "選擇人物",
|
||||||
"search_filter_star_rating": "評分",
|
"search_filter_star_rating": "評分",
|
||||||
"search_for": "搜尋",
|
"search_for": "搜尋",
|
||||||
@@ -2183,6 +2190,7 @@
|
|||||||
"support": "支援",
|
"support": "支援",
|
||||||
"support_and_feedback": "支援與回饋",
|
"support_and_feedback": "支援與回饋",
|
||||||
"support_third_party_description": "您安裝的 Immich 是由第三方打包的。您遇到的問題可能是該套件造成的,所以請先使用下面的連結向他們提出問題。",
|
"support_third_party_description": "您安裝的 Immich 是由第三方打包的。您遇到的問題可能是該套件造成的,所以請先使用下面的連結向他們提出問題。",
|
||||||
|
"supporter": "支持者",
|
||||||
"swap_merge_direction": "交換合併方向",
|
"swap_merge_direction": "交換合併方向",
|
||||||
"sync": "同步",
|
"sync": "同步",
|
||||||
"sync_albums": "同步相簿",
|
"sync_albums": "同步相簿",
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
android:pathPrefix="/albums/" />
|
android:pathPrefix="/albums/" />
|
||||||
<data
|
<data
|
||||||
android:host="my.immich.app"
|
android:host="my.immich.app"
|
||||||
android:pathPrefix="/memories/" />
|
android:pathPrefix="/people/" />
|
||||||
<data
|
<data
|
||||||
android:host="my.immich.app"
|
android:host="my.immich.app"
|
||||||
android:path="/memory" />
|
android:path="/memory" />
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ class DriftPeopleService {
|
|||||||
|
|
||||||
const DriftPeopleService(this._repository, this._personApiRepository);
|
const DriftPeopleService(this._repository, this._personApiRepository);
|
||||||
|
|
||||||
|
Future<DriftPerson?> get(String personId) {
|
||||||
|
return _repository.get(personId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<DriftPerson>> getAssetPeople(String assetId) {
|
Future<List<DriftPerson>> getAssetPeople(String assetId) {
|
||||||
return _repository.getAssetPeople(assetId);
|
return _repository.getAssetPeople(assetId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:drift/drift.dart';
|
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/domain/models/person.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
@@ -7,6 +8,13 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftPeopleRepository(this._db) : super(_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 {
|
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||||
final query = _db.select(_db.assetFaceEntity).join([
|
final query = _db.select(_db.assetFaceEntity).join([
|
||||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||||
@@ -19,19 +27,28 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<DriftPerson>> getAllPeople() async {
|
Future<List<DriftPerson>> getAllPeople() async {
|
||||||
|
final people = _db.personEntity;
|
||||||
|
final faces = _db.assetFaceEntity;
|
||||||
|
final assets = _db.remoteAssetEntity;
|
||||||
|
|
||||||
final query =
|
final query =
|
||||||
_db.select(_db.personEntity).join([
|
_db.select(people).join([
|
||||||
leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)),
|
innerJoin(faces, faces.personId.equalsExp(people.id)),
|
||||||
|
innerJoin(assets, assets.id.equalsExp(faces.assetId)),
|
||||||
])
|
])
|
||||||
..where(_db.personEntity.isHidden.equals(false))
|
..where(
|
||||||
..groupBy([_db.personEntity.id], having: _db.assetFaceEntity.id.count().isBiggerOrEqualValue(3))
|
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([
|
..orderBy([
|
||||||
OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc),
|
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
|
||||||
OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc),
|
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
final person = row.readTable(_db.personEntity);
|
final person = row.readTable(people);
|
||||||
return person.toDto();
|
return person.toDto();
|
||||||
}).get();
|
}).get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ServerInfo {
|
|||||||
|
|
||||||
const ServerInfo({
|
const ServerInfo({
|
||||||
required this.serverVersion,
|
required this.serverVersion,
|
||||||
required this.latestVersion,
|
this.latestVersion,
|
||||||
required this.serverFeatures,
|
required this.serverFeatures,
|
||||||
required this.serverConfig,
|
required this.serverConfig,
|
||||||
required this.serverDiskInfo,
|
required this.serverDiskInfo,
|
||||||
|
|||||||
@@ -1,27 +1,45 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
class TimelineRow extends MultiChildRenderObjectWidget {
|
||||||
final double dimension;
|
final double height;
|
||||||
|
final List<double> widths;
|
||||||
final double spacing;
|
final double spacing;
|
||||||
final TextDirection textDirection;
|
final TextDirection textDirection;
|
||||||
|
|
||||||
const FixedTimelineRow({
|
const TimelineRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.dimension,
|
required this.height,
|
||||||
|
required this.widths,
|
||||||
required this.spacing,
|
required this.spacing,
|
||||||
required this.textDirection,
|
required this.textDirection,
|
||||||
required super.children,
|
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
|
@override
|
||||||
RenderObject createRenderObject(BuildContext context) {
|
RenderObject createRenderObject(BuildContext context) {
|
||||||
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
|
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||||
renderObject.dimension = dimension;
|
renderObject.height = height;
|
||||||
|
renderObject.widths = widths;
|
||||||
renderObject.spacing = spacing;
|
renderObject.spacing = spacing;
|
||||||
renderObject.textDirection = textDirection;
|
renderObject.textDirection = textDirection;
|
||||||
}
|
}
|
||||||
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
|||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(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(DoubleProperty('spacing', spacing));
|
||||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||||
}
|
}
|
||||||
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
|
|||||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||||
RenderFixedRow({
|
RenderFixedRow({
|
||||||
List<RenderBox>? children,
|
List<RenderBox>? children,
|
||||||
required double dimension,
|
required double height,
|
||||||
|
required List<double> widths,
|
||||||
required double spacing,
|
required double spacing,
|
||||||
required TextDirection textDirection,
|
required TextDirection textDirection,
|
||||||
}) : _dimension = dimension,
|
}) : _height = height,
|
||||||
|
_widths = widths,
|
||||||
_spacing = spacing,
|
_spacing = spacing,
|
||||||
_textDirection = textDirection {
|
_textDirection = textDirection {
|
||||||
addAll(children);
|
addAll(children);
|
||||||
}
|
}
|
||||||
|
|
||||||
double get dimension => _dimension;
|
double get height => _height;
|
||||||
double _dimension;
|
double _height;
|
||||||
|
|
||||||
set dimension(double value) {
|
set height(double value) {
|
||||||
if (_dimension == value) return;
|
if (_height == value) return;
|
||||||
_dimension = value;
|
_height = value;
|
||||||
|
markNeedsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<double> get widths => _widths;
|
||||||
|
List<double> _widths;
|
||||||
|
|
||||||
|
set widths(List<double> value) {
|
||||||
|
if (listEquals(_widths, value)) return;
|
||||||
|
_widths = value;
|
||||||
markNeedsLayout();
|
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
|
@override
|
||||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||||
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
|
|||||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double computeMinIntrinsicHeight(double width) => dimension;
|
double computeMinIntrinsicHeight(double width) => height;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
double computeMaxIntrinsicHeight(double width) => height;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||||
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
|
|||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
super.debugFillProperties(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(DoubleProperty('spacing', spacing));
|
||||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||||
}
|
}
|
||||||
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use the entire width of the parent for the row.
|
// Use the entire width of the parent for the row.
|
||||||
size = Size(constraints.maxWidth, dimension);
|
size = Size(constraints.maxWidth, height);
|
||||||
// Each tile is forced to be dimension x dimension.
|
|
||||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
|
||||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
int childIndex = 0;
|
||||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
|
||||||
// Layout each child horizontally.
|
// 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);
|
child.layout(childConstraints, parentUsesSize: false);
|
||||||
final childParentData = child.parentData! as _RowParentData;
|
final childParentData = child.parentData! as _RowParentData;
|
||||||
childParentData.offset = offset;
|
childParentData.offset = Offset(currentX, 0);
|
||||||
offset += Offset(dx, 0);
|
|
||||||
child = childParentData.nextSibling;
|
child = childParentData.nextSibling;
|
||||||
|
childIndex++;
|
||||||
|
|
||||||
|
if (child != null && childIndex < widths.length) {
|
||||||
|
final nextWidth = widths[childIndex];
|
||||||
|
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/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/asset_viewer/asset_viewer.page.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||||
@@ -78,6 +80,7 @@ class FixedSegment extends Segment {
|
|||||||
assetCount: numberOfAssets,
|
assetCount: numberOfAssets,
|
||||||
tileHeight: tileHeight,
|
tileHeight: tileHeight,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
|
columnCount: columnCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
final int assetCount;
|
final int assetCount;
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
final double spacing;
|
final double spacing;
|
||||||
|
final int columnCount;
|
||||||
|
|
||||||
const _FixedSegmentRow({
|
const _FixedSegmentRow({
|
||||||
required this.assetIndex,
|
required this.assetIndex,
|
||||||
required this.assetCount,
|
required this.assetCount,
|
||||||
required this.tileHeight,
|
required this.tileHeight,
|
||||||
required this.spacing,
|
required this.spacing,
|
||||||
|
required this.columnCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
|
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||||
|
|
||||||
if (isScrubbing) {
|
if (isScrubbing) {
|
||||||
return _buildPlaceholder(context);
|
return _buildPlaceholder(context);
|
||||||
}
|
}
|
||||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
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>>(
|
return FutureBuilder<List<BaseAsset>>(
|
||||||
@@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return _buildPlaceholder(context);
|
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);
|
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
Widget _buildAssetRow(
|
||||||
return FixedTimelineRow(
|
BuildContext context,
|
||||||
dimension: tileHeight,
|
List<BaseAsset> assets,
|
||||||
spacing: spacing,
|
TimelineService timelineService,
|
||||||
textDirection: Directionality.of(context),
|
bool isDynamicLayout,
|
||||||
children: [
|
) {
|
||||||
for (int i = 0; i < assets.length; i++)
|
final children = [
|
||||||
TimelineAssetIndexWrapper(
|
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,
|
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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
|
|||||||
Size size = kTimelineFixedTileExtent,
|
Size size = kTimelineFixedTileExtent,
|
||||||
double spacing = kTimelineSpacing,
|
double spacing = kTimelineSpacing,
|
||||||
}) => RepaintBoundary(
|
}) => RepaintBoundary(
|
||||||
child: FixedTimelineRow(
|
child: TimelineRow.fixed(
|
||||||
dimension: size.height,
|
dimension: size.height,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
textDirection: Directionality.of(context),
|
textDirection: Directionality.of(context),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
|||||||
: super(
|
: super(
|
||||||
const ServerInfo(
|
const ServerInfo(
|
||||||
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||||
latestVersion: null,
|
|
||||||
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
|
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
|
||||||
serverConfig: ServerConfig(
|
serverConfig: ServerConfig(
|
||||||
trashDays: 30,
|
trashDays: 30,
|
||||||
@@ -104,7 +103,9 @@ final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfo>
|
|||||||
|
|
||||||
final versionWarningPresentProvider = Provider.family<bool, UserDto?>((ref, user) {
|
final versionWarningPresentProvider = Provider.family<bool, UserDto?>((ref, user) {
|
||||||
final serverInfo = ref.watch(serverInfoProvider);
|
final serverInfo = ref.watch(serverInfoProvider);
|
||||||
return serverInfo.versionStatus == VersionStatus.clientOutOfDate ||
|
return switch (serverInfo.versionStatus) {
|
||||||
serverInfo.versionStatus == VersionStatus.error ||
|
VersionStatus.clientOutOfDate || VersionStatus.error => true,
|
||||||
((user?.isAdmin ?? false) && serverInfo.versionStatus == VersionStatus.serverOutOfDate);
|
VersionStatus.serverOutOfDate => serverInfo.latestVersion != null && (user?.isAdmin ?? false),
|
||||||
|
VersionStatus.upToDate => false,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service;
|
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/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/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.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/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
|
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/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/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -33,6 +35,7 @@ final deepLinkServiceProvider = Provider(
|
|||||||
ref.watch(beta_asset_provider.assetServiceProvider),
|
ref.watch(beta_asset_provider.assetServiceProvider),
|
||||||
ref.watch(remoteAlbumServiceProvider),
|
ref.watch(remoteAlbumServiceProvider),
|
||||||
ref.watch(driftMemoryServiceProvider),
|
ref.watch(driftMemoryServiceProvider),
|
||||||
|
ref.watch(driftPeopleServiceProvider),
|
||||||
ref.watch(currentUserProvider),
|
ref.watch(currentUserProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -49,7 +52,8 @@ class DeepLinkService {
|
|||||||
final TimelineFactory _betaTimelineFactory;
|
final TimelineFactory _betaTimelineFactory;
|
||||||
final beta_asset_service.AssetService _betaAssetService;
|
final beta_asset_service.AssetService _betaAssetService;
|
||||||
final RemoteAlbumService _betaRemoteAlbumService;
|
final RemoteAlbumService _betaRemoteAlbumService;
|
||||||
final DriftMemoryService _betaMemoryServiceProvider;
|
final DriftMemoryService _betaMemoryService;
|
||||||
|
final DriftPeopleService _betaPeopleService;
|
||||||
|
|
||||||
final UserDto? _currentUser;
|
final UserDto? _currentUser;
|
||||||
|
|
||||||
@@ -62,7 +66,8 @@ class DeepLinkService {
|
|||||||
this._betaTimelineFactory,
|
this._betaTimelineFactory,
|
||||||
this._betaAssetService,
|
this._betaAssetService,
|
||||||
this._betaRemoteAlbumService,
|
this._betaRemoteAlbumService,
|
||||||
this._betaMemoryServiceProvider,
|
this._betaMemoryService,
|
||||||
|
this._betaPeopleService,
|
||||||
this._currentUser,
|
this._currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,6 +89,7 @@ class DeepLinkService {
|
|||||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
||||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||||
|
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''),
|
||||||
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
|
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
|
||||||
_ => null,
|
_ => 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}';
|
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 assetRegex = RegExp('/photos/($uuidRegex)');
|
||||||
final albumRegex = RegExp('/albums/($uuidRegex)');
|
final albumRegex = RegExp('/albums/($uuidRegex)');
|
||||||
|
final peopleRegex = RegExp('/people/($uuidRegex)');
|
||||||
|
|
||||||
PageRouteInfo<dynamic>? deepLinkRoute;
|
PageRouteInfo<dynamic>? deepLinkRoute;
|
||||||
if (assetRegex.hasMatch(path)) {
|
if (assetRegex.hasMatch(path)) {
|
||||||
@@ -114,6 +121,9 @@ class DeepLinkService {
|
|||||||
} else if (albumRegex.hasMatch(path)) {
|
} else if (albumRegex.hasMatch(path)) {
|
||||||
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
||||||
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
||||||
|
} else if (peopleRegex.hasMatch(path)) {
|
||||||
|
final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? '';
|
||||||
|
deepLinkRoute = await _buildPeopleDeepLink(peopleId);
|
||||||
} else if (path == "/memory") {
|
} else if (path == "/memory") {
|
||||||
deepLinkRoute = await _buildMemoryDeepLink(null);
|
deepLinkRoute = await _buildMemoryDeepLink(null);
|
||||||
}
|
}
|
||||||
@@ -136,9 +146,9 @@ class DeepLinkService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
|
memories = await _betaMemoryService.getMemoryLane(_currentUser.id);
|
||||||
} else {
|
} else {
|
||||||
final memory = await _betaMemoryServiceProvider.get(memoryId);
|
final memory = await _betaMemoryService.get(memoryId);
|
||||||
if (memory != null) {
|
if (memory != null) {
|
||||||
memories = [memory];
|
memories = [memory];
|
||||||
}
|
}
|
||||||
@@ -225,4 +235,18 @@ class DeepLinkService {
|
|||||||
|
|
||||||
return DriftActivitiesRoute(album: album);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ class AppBarServerInfo extends HookConsumerWidget {
|
|||||||
final bool showVersionWarning = ref.watch(versionWarningPresentProvider(user));
|
final bool showVersionWarning = ref.watch(versionWarningPresentProvider(user));
|
||||||
|
|
||||||
final appInfo = useState({});
|
final appInfo = useState({});
|
||||||
const titleFontSize = 12.0;
|
|
||||||
const contentFontSize = 11.0;
|
|
||||||
|
|
||||||
getPackageInfo() async {
|
getPackageInfo() async {
|
||||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||||
@@ -37,176 +35,38 @@ class AppBarServerInfo extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const divider = Divider(thickness: 1);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (showVersionWarning) ...[
|
if (showVersionWarning) ...[const ServerUpdateNotification(), divider],
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 8.0), child: ServerUpdateNotification()),
|
_ServerInfoItem(
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
label: "server_info_box_app_version".tr(),
|
||||||
],
|
text: "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
divider,
|
||||||
Row(
|
_ServerInfoItem(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
label: "server_version".tr(),
|
||||||
children: [
|
text: serverInfoState.serverVersion.major > 0
|
||||||
Expanded(
|
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
|
||||||
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_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
|
||||||
if (serverInfoState.latestVersion != null) ...[
|
if (serverInfoState.latestVersion != null) ...[
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
divider,
|
||||||
Row(
|
_ServerInfoItem(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
label: "latest_version".tr(),
|
||||||
children: [
|
text: serverInfoState.latestVersion!.major > 0
|
||||||
Expanded(
|
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
|
||||||
child: Padding(
|
: "--",
|
||||||
padding: const EdgeInsets.only(left: 10.0),
|
tooltip: true,
|
||||||
child: Row(
|
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
|
||||||
children: [
|
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
|
||||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
: null,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.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),
|
title: "asset_list_layout_sub_title".t(context: context),
|
||||||
icon: Icons.view_module_outlined,
|
icon: Icons.view_module_outlined,
|
||||||
),
|
),
|
||||||
SettingsSwitchListTile(
|
if (!Store.isBetaTimelineEnabled)
|
||||||
valueNotifier: useDynamicLayout,
|
SettingsSwitchListTile(
|
||||||
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
valueNotifier: useDynamicLayout,
|
||||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
||||||
),
|
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||||
|
),
|
||||||
SettingsSliderListTile(
|
SettingsSliderListTile(
|
||||||
valueNotifier: tilesPerRow,
|
valueNotifier: tilesPerRow,
|
||||||
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
||||||
|
|||||||
@@ -1217,10 +1217,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.16.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1910,10 +1910,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.6"
|
||||||
thumbhash:
|
thumbhash:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -406,9 +406,12 @@ importers:
|
|||||||
'@react-email/render':
|
'@react-email/render':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@socket.io/redis-adapter':
|
'@socket.io/postgres-adapter':
|
||||||
specifier: ^8.3.0
|
specifier: ^0.5.0
|
||||||
version: 8.3.0(socket.io-adapter@2.5.6)
|
version: 0.5.0(socket.io-adapter@2.5.6)
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.16.0
|
||||||
|
version: 8.16.0
|
||||||
ajv:
|
ajv:
|
||||||
specifier: ^8.17.1
|
specifier: ^8.17.1
|
||||||
version: 8.17.1
|
version: 8.17.1
|
||||||
@@ -562,6 +565,9 @@ importers:
|
|||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.1
|
specifier: ^4.8.1
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
socket.io-adapter:
|
||||||
|
specifier: ^2.5.6
|
||||||
|
version: 2.5.6
|
||||||
tailwindcss-preset-email:
|
tailwindcss-preset-email:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.4.1(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
|
||||||
@@ -3402,6 +3408,10 @@ packages:
|
|||||||
'@microsoft/tsdoc@0.16.0':
|
'@microsoft/tsdoc@0.16.0':
|
||||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
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':
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
@@ -4310,9 +4320,9 @@ packages:
|
|||||||
'@socket.io/component-emitter@3.1.2':
|
'@socket.io/component-emitter@3.1.2':
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
'@socket.io/redis-adapter@8.3.0':
|
'@socket.io/postgres-adapter@0.5.0':
|
||||||
resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==}
|
resolution: {integrity: sha512-s1vFsatB4lS429ZbeAi8ju+mZMgtgdSmi9UsZsdcEG++vVtX5z10yDEt4TV8saePscvvGjs6uXvJfMCxz8+M2Q==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
socket.io-adapter: ^2.5.4
|
socket.io-adapter: ^2.5.4
|
||||||
|
|
||||||
@@ -9285,9 +9295,6 @@ packages:
|
|||||||
not@0.1.0:
|
not@0.1.0:
|
||||||
resolution: {integrity: sha512-5PDmaAsVfnWUgTUbJ3ERwn7u79Z0dYxN9ErxCpVJJqe2RK0PJ3z+iFUxuqjwtlDDegXvtWoxD/3Fzxox7tFGWA==}
|
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:
|
npm-run-path@4.0.1:
|
||||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -11619,10 +11626,6 @@ packages:
|
|||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uid2@1.0.0:
|
|
||||||
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
|
|
||||||
engines: {node: '>= 4.0.0'}
|
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -15426,6 +15429,8 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.16.0': {}
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
|
'@msgpack/msgpack@2.8.0': {}
|
||||||
|
|
||||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -16303,13 +16308,15 @@ snapshots:
|
|||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
'@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:
|
dependencies:
|
||||||
|
'@msgpack/msgpack': 2.8.0
|
||||||
|
'@types/pg': 8.16.0
|
||||||
debug: 4.3.7
|
debug: 4.3.7
|
||||||
notepack.io: 3.0.1
|
pg: 8.18.0
|
||||||
socket.io-adapter: 2.5.6
|
socket.io-adapter: 2.5.6
|
||||||
uid2: 1.0.0
|
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
- pg-native
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@sphinxxxx/color-conversion@2.2.2': {}
|
'@sphinxxxx/color-conversion@2.2.2': {}
|
||||||
@@ -22280,8 +22287,6 @@ snapshots:
|
|||||||
|
|
||||||
not@0.1.0: {}
|
not@0.1.0: {}
|
||||||
|
|
||||||
notepack.io@3.0.1: {}
|
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -25030,8 +25035,6 @@ snapshots:
|
|||||||
uglify-js@3.19.3:
|
uglify-js@3.19.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
uid2@1.0.0: {}
|
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lukeed/csprng': 1.1.0
|
'@lukeed/csprng': 1.1.0
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@react-email/components": "^0.5.0",
|
"@react-email/components": "^0.5.0",
|
||||||
"@react-email/render": "^1.1.2",
|
"@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",
|
"ajv": "^8.17.1",
|
||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sirv": "^3.0.0",
|
"sirv": "^3.0.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-adapter": "^2.5.6",
|
||||||
"tailwindcss-preset-email": "^1.4.0",
|
"tailwindcss-preset-email": "^1.4.0",
|
||||||
"thumbhash": "^0.1.1",
|
"thumbhash": "^0.1.1",
|
||||||
"transformation-matrix": "^3.1.0",
|
"transformation-matrix": "^3.1.0",
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import cookieParser from 'cookie-parser';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import sirv from 'sirv';
|
import sirv from 'sirv';
|
||||||
import { excludePaths, serverVersion } from 'src/constants';
|
import { excludePaths, serverVersion } from 'src/constants';
|
||||||
|
import { SocketIoAdapter } from 'src/enum';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
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 { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||||
@@ -25,6 +26,7 @@ export async function configureExpress(
|
|||||||
{
|
{
|
||||||
permitSwaggerWrite = true,
|
permitSwaggerWrite = true,
|
||||||
ssr,
|
ssr,
|
||||||
|
socketIoAdapter,
|
||||||
}: {
|
}: {
|
||||||
/**
|
/**
|
||||||
* Whether to allow swagger module to write to the specs.json
|
* 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
|
* Service to use for server-side rendering
|
||||||
*/
|
*/
|
||||||
ssr: typeof ApiService | typeof MaintenanceWorkerService;
|
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);
|
const configRepository = app.get(ConfigRepository);
|
||||||
@@ -55,7 +61,7 @@ export async function configureExpress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.setGlobalPrefix('api', { exclude: excludePaths });
|
app.setGlobalPrefix('api', { exclude: excludePaths });
|
||||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
|
||||||
|
|
||||||
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
|
|||||||
import { DownloadController } from 'src/controllers/download.controller';
|
import { DownloadController } from 'src/controllers/download.controller';
|
||||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||||
import { FaceController } from 'src/controllers/face.controller';
|
import { FaceController } from 'src/controllers/face.controller';
|
||||||
|
import { InternalController } from 'src/controllers/internal.controller';
|
||||||
import { JobController } from 'src/controllers/job.controller';
|
import { JobController } from 'src/controllers/job.controller';
|
||||||
import { LibraryController } from 'src/controllers/library.controller';
|
import { LibraryController } from 'src/controllers/library.controller';
|
||||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||||
@@ -51,6 +52,7 @@ export const controllers = [
|
|||||||
DownloadController,
|
DownloadController,
|
||||||
DuplicateController,
|
DuplicateController,
|
||||||
FaceController,
|
FaceController,
|
||||||
|
InternalController,
|
||||||
JobController,
|
JobController,
|
||||||
LibraryController,
|
LibraryController,
|
||||||
MaintenanceController,
|
MaintenanceController,
|
||||||
|
|||||||
22
server/src/controllers/internal.controller.ts
Normal file
22
server/src/controllers/internal.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||||
import { faceStub } from 'test/fixtures/face.stub';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
|
|
||||||
describe('mapAsset', () => {
|
describe('mapAsset', () => {
|
||||||
describe('peopleWithFaces', () => {
|
describe('peopleWithFaces', () => {
|
||||||
it('should transform all faces when a person has multiple faces in the same image', () => {
|
it('should transform all faces when a person has multiple faces in the same image', () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
const face1 = {
|
const face1 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
@@ -18,8 +18,6 @@ describe('mapAsset', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const face2 = {
|
const face2 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
id: 'assetFaceId-second',
|
|
||||||
boundingBoxX1: 300,
|
boundingBoxX1: 300,
|
||||||
boundingBoxY1: 400,
|
boundingBoxY1: 400,
|
||||||
boundingBoxX2: 400,
|
boundingBoxX2: 400,
|
||||||
@@ -28,16 +26,22 @@ describe('mapAsset', () => {
|
|||||||
imageHeight: 800,
|
imageHeight: 800,
|
||||||
};
|
};
|
||||||
|
|
||||||
const asset = {
|
const asset = AssetFactory.from()
|
||||||
...assetStub.withCropEdit,
|
.face(face1, (builder) => builder.person(person))
|
||||||
faces: [face1, face2],
|
.face(face2, (builder) => builder.person(person))
|
||||||
exifInfo: {
|
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||||
exifImageWidth: 1000,
|
.edit({
|
||||||
exifImageHeight: 800,
|
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).toBeDefined();
|
||||||
expect(result.people).toHaveLength(1);
|
expect(result.people).toHaveLength(1);
|
||||||
@@ -61,32 +65,22 @@ describe('mapAsset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should transform unassigned faces with edits and dimensions', () => {
|
it('should transform unassigned faces with edits and dimensions', () => {
|
||||||
const unassignedFace = {
|
const unassignedFace = AssetFaceFactory.create({
|
||||||
...faceStub.noPerson1,
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
imageHeight: 800,
|
imageHeight: 800,
|
||||||
};
|
});
|
||||||
|
|
||||||
const asset = {
|
const asset = AssetFactory.from()
|
||||||
...assetStub.withCropEdit,
|
.face(unassignedFace)
|
||||||
faces: [unassignedFace],
|
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||||
exifInfo: {
|
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
||||||
exifImageWidth: 1000,
|
.build();
|
||||||
exifImageHeight: 800,
|
|
||||||
},
|
|
||||||
edits: [
|
|
||||||
{
|
|
||||||
action: AssetEditAction.Crop,
|
|
||||||
parameters: { x: 50, y: 50, width: 500, height: 400 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = mapAsset(asset as any);
|
const result = mapAsset(asset);
|
||||||
|
|
||||||
expect(result.unassignedFaces).toBeDefined();
|
expect(result.unassignedFaces).toBeDefined();
|
||||||
expect(result.unassignedFaces).toHaveLength(1);
|
expect(result.unassignedFaces).toHaveLength(1);
|
||||||
@@ -101,10 +95,6 @@ describe('mapAsset', () => {
|
|||||||
|
|
||||||
it('should handle multiple people each with multiple faces', () => {
|
it('should handle multiple people each with multiple faces', () => {
|
||||||
const person1Face1 = {
|
const person1Face1 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
id: 'face-1-1',
|
|
||||||
person: personStub.withName,
|
|
||||||
personId: personStub.withName.id,
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
@@ -114,10 +104,6 @@ describe('mapAsset', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const person1Face2 = {
|
const person1Face2 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
id: 'face-1-2',
|
|
||||||
person: personStub.withName,
|
|
||||||
personId: personStub.withName.id,
|
|
||||||
boundingBoxX1: 300,
|
boundingBoxX1: 300,
|
||||||
boundingBoxY1: 300,
|
boundingBoxY1: 300,
|
||||||
boundingBoxX2: 400,
|
boundingBoxX2: 400,
|
||||||
@@ -127,10 +113,6 @@ describe('mapAsset', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const person2Face1 = {
|
const person2Face1 = {
|
||||||
...faceStub.mergeFace1,
|
|
||||||
id: 'face-2-1',
|
|
||||||
person: personStub.mergePerson,
|
|
||||||
personId: personStub.mergePerson.id,
|
|
||||||
boundingBoxX1: 500,
|
boundingBoxX1: 500,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 600,
|
boundingBoxX2: 600,
|
||||||
@@ -139,23 +121,22 @@ describe('mapAsset', () => {
|
|||||||
imageHeight: 800,
|
imageHeight: 800,
|
||||||
};
|
};
|
||||||
|
|
||||||
const asset = {
|
const person = PersonFactory.create({ id: 'person-1' });
|
||||||
...assetStub.withCropEdit,
|
|
||||||
faces: [person1Face1, person1Face2, person2Face1],
|
|
||||||
exifInfo: {
|
|
||||||
exifImageWidth: 1000,
|
|
||||||
exifImageHeight: 800,
|
|
||||||
},
|
|
||||||
edits: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
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).toBeDefined();
|
||||||
expect(result.people).toHaveLength(2);
|
expect(result.people).toHaveLength(2);
|
||||||
|
|
||||||
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
|
const person1 = result.people!.find((p) => p.id === 'person-1');
|
||||||
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
|
const person2 = result.people!.find((p) => p.id === 'person-2');
|
||||||
|
|
||||||
expect(person1).toBeDefined();
|
expect(person1).toBeDefined();
|
||||||
expect(person1!.faces).toHaveLength(2);
|
expect(person1!.faces).toHaveLength(2);
|
||||||
@@ -173,10 +154,6 @@ describe('mapAsset', () => {
|
|||||||
|
|
||||||
it('should combine faces of the same person into a single entry', () => {
|
it('should combine faces of the same person into a single entry', () => {
|
||||||
const face1 = {
|
const face1 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
id: 'face-1',
|
|
||||||
person: personStub.withName,
|
|
||||||
personId: personStub.withName.id,
|
|
||||||
boundingBoxX1: 100,
|
boundingBoxX1: 100,
|
||||||
boundingBoxY1: 100,
|
boundingBoxY1: 100,
|
||||||
boundingBoxX2: 200,
|
boundingBoxX2: 200,
|
||||||
@@ -186,10 +163,6 @@ describe('mapAsset', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const face2 = {
|
const face2 = {
|
||||||
...faceStub.primaryFace1,
|
|
||||||
id: 'face-2',
|
|
||||||
person: personStub.withName,
|
|
||||||
personId: personStub.withName.id,
|
|
||||||
boundingBoxX1: 300,
|
boundingBoxX1: 300,
|
||||||
boundingBoxY1: 300,
|
boundingBoxY1: 300,
|
||||||
boundingBoxX2: 400,
|
boundingBoxX2: 400,
|
||||||
@@ -198,24 +171,21 @@ describe('mapAsset', () => {
|
|||||||
imageHeight: 800,
|
imageHeight: 800,
|
||||||
};
|
};
|
||||||
|
|
||||||
const asset = {
|
const person = PersonFactory.create();
|
||||||
...assetStub.withCropEdit,
|
|
||||||
faces: [face1, face2],
|
|
||||||
exifInfo: {
|
|
||||||
exifImageWidth: 1000,
|
|
||||||
exifImageHeight: 800,
|
|
||||||
},
|
|
||||||
edits: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
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).toBeDefined();
|
||||||
expect(result.people).toHaveLength(1);
|
expect(result.people).toHaveLength(1);
|
||||||
|
|
||||||
const person = result.people![0];
|
expect(result.people![0].id).toBe(person.id);
|
||||||
expect(person.id).toBe(personStub.withName.id);
|
expect(result.people![0].faces).toHaveLength(2);
|
||||||
expect(person.faces).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
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';
|
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||||
|
|
||||||
export class EnvDto {
|
export class EnvDto {
|
||||||
@@ -140,6 +140,11 @@ export class EnvDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
IMMICH_WORKERS_EXCLUDE?: string;
|
IMMICH_WORKERS_EXCLUDE?: string;
|
||||||
|
|
||||||
|
@IsEnum(SocketIoAdapter)
|
||||||
|
@Optional()
|
||||||
|
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
|
||||||
|
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Optional()
|
@Optional()
|
||||||
DB_DATABASE_NAME?: string;
|
DB_DATABASE_NAME?: string;
|
||||||
|
|||||||
@@ -518,6 +518,11 @@ export enum ImmichTelemetry {
|
|||||||
Job = 'job',
|
Job = 'job',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SocketIoAdapter {
|
||||||
|
BroadcastChannel = 'broadcastchannel',
|
||||||
|
Postgres = 'postgres',
|
||||||
|
}
|
||||||
|
|
||||||
export enum ExifOrientation {
|
export enum ExifOrientation {
|
||||||
Horizontal = 1,
|
Horizontal = 1,
|
||||||
MirrorHorizontal = 2,
|
MirrorHorizontal = 2,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely, sql } from 'kysely';
|
||||||
import { CommandFactory } from 'nest-commander';
|
import { CommandFactory } from 'nest-commander';
|
||||||
import { ChildProcess, fork } from 'node:child_process';
|
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import { PostgresError } from 'postgres';
|
import { PostgresError } from 'postgres';
|
||||||
@@ -18,7 +17,7 @@ class Workers {
|
|||||||
/**
|
/**
|
||||||
* Currently running 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
|
* Fail-safe in case anything dies during restart
|
||||||
@@ -101,25 +100,23 @@ class Workers {
|
|||||||
const basePath = dirname(__filename);
|
const basePath = dirname(__filename);
|
||||||
const workerFile = join(basePath, 'workers', `${name}.js`);
|
const workerFile = join(basePath, 'workers', `${name}.js`);
|
||||||
|
|
||||||
let anyWorker: Worker | ChildProcess;
|
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
|
||||||
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
|
const workerData: { inspectorPort?: number } = {};
|
||||||
|
|
||||||
if (name === ImmichWorker.Api) {
|
if (inspectArg) {
|
||||||
const worker = fork(workerFile, [], {
|
const inspectorPorts: Record<ImmichWorker, number> = {
|
||||||
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
|
[ImmichWorker.Api]: 9230,
|
||||||
});
|
[ImmichWorker.Microservices]: 9231,
|
||||||
|
[ImmichWorker.Maintenance]: 9232,
|
||||||
kill = (signal) => void worker.kill(signal);
|
};
|
||||||
anyWorker = worker;
|
workerData.inspectorPort = inspectorPorts[name];
|
||||||
} else {
|
|
||||||
const worker = new Worker(workerFile);
|
|
||||||
|
|
||||||
kill = async () => void (await worker.terminate());
|
|
||||||
anyWorker = worker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
anyWorker.on('error', (error) => this.onError(name, error));
|
const worker = new Worker(workerFile, { workerData });
|
||||||
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
|
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 };
|
this.workers[name] = { kill };
|
||||||
}
|
}
|
||||||
@@ -152,8 +149,8 @@ class Workers {
|
|||||||
console.error(`${name} worker exited with code ${exitCode}`);
|
console.error(`${name} worker exited with code ${exitCode}`);
|
||||||
|
|
||||||
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
||||||
console.error('Killing api process');
|
console.error('Terminating api worker');
|
||||||
void this.workers[ImmichWorker.Api].kill('SIGTERM');
|
void this.workers[ImmichWorker.Api].kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Next,
|
Next,
|
||||||
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
@@ -25,12 +26,15 @@ import { ImmichCookie } from 'src/enum';
|
|||||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
import { sendFile } from 'src/utils/file';
|
import { sendFile } from 'src/utils/file';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
import { FilenameParamDto } from 'src/validation';
|
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 { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
||||||
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||||
@@ -131,4 +135,14 @@ export class MaintenanceWorkerController {
|
|||||||
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
||||||
void this.service.setAction(dto);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-webs
|
|||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
@@ -290,6 +291,9 @@ export class MaintenanceWorkerService {
|
|||||||
|
|
||||||
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
||||||
if (!lock) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,4 +355,25 @@ export class MaintenanceWorkerService {
|
|||||||
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
||||||
this.appRepository.exitApp();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
server/src/middleware/broadcast-channel.adapter.ts
Normal file
80
server/src/middleware/broadcast-channel.adapter.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,21 +1,103 @@
|
|||||||
import { INestApplicationContext } from '@nestjs/common';
|
import { INestApplication, Logger } from '@nestjs/common';
|
||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
import { createAdapter } from '@socket.io/redis-adapter';
|
import { Pool, PoolConfig } from 'pg';
|
||||||
import { Redis } from 'ioredis';
|
import type { ServerOptions } from 'socket.io';
|
||||||
import { 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 { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { asPostgresConnectionConfig } from 'src/utils/database';
|
||||||
|
|
||||||
export class WebSocketAdapter extends IoAdapter {
|
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
||||||
constructor(private app: INestApplicationContext) {
|
|
||||||
|
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);
|
super(app);
|
||||||
|
this.adapterConstructor = createBroadcastChannelAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
createIOServer(port: number, options?: ServerOptions): any {
|
createIOServer(port: number, options?: ServerOptions): any {
|
||||||
const { redis } = this.app.get(ConfigRepository).getEnv();
|
|
||||||
const server = super.createIOServer(port, options);
|
const server = super.createIOServer(port, options);
|
||||||
const pubClient = new Redis(redis);
|
server.adapter(this.adapterConstructor);
|
||||||
const subClient = pubClient.duplicate();
|
|
||||||
server.adapter(createAdapter(pubClient, subClient));
|
|
||||||
return server;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { ExitCode } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
@@ -24,24 +21,17 @@ export class AppRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
||||||
const server = new SocketIO();
|
const { port } = new ConfigRepository().getEnv();
|
||||||
const { redis } = new ConfigRepository().getEnv();
|
const url = `http://127.0.0.1:${port}/api/internal/restart`;
|
||||||
const pubClient = new Redis({ ...redis, lazyConnect: true });
|
|
||||||
const subClient = pubClient.duplicate();
|
|
||||||
|
|
||||||
await Promise.all([pubClient.connect(), subClient.connect()]);
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
server.adapter(createAdapter(pubClient, subClient));
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(state),
|
||||||
// => 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to trigger app restart: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
LogFormat,
|
LogFormat,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
QueueName,
|
QueueName,
|
||||||
|
SocketIoAdapter,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
||||||
import { setDifference } from 'src/utils/set';
|
import { setDifference } from 'src/utils/set';
|
||||||
@@ -116,6 +117,10 @@ export interface EnvData {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socketIo: {
|
||||||
|
adapter: SocketIoAdapter;
|
||||||
|
};
|
||||||
|
|
||||||
noColor: boolean;
|
noColor: boolean;
|
||||||
nodeVersion?: string;
|
nodeVersion?: string;
|
||||||
}
|
}
|
||||||
@@ -346,6 +351,10 @@ const getEnv = (): EnvData => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
socketIo: {
|
||||||
|
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
|
||||||
|
},
|
||||||
|
|
||||||
noColor: !!dto.NO_COLOR,
|
noColor: !!dto.NO_COLOR,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
|||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||||
import { AssetFactory } from 'test/factories/asset.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 { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { userStub } from 'test/fixtures/user.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 () => {
|
it('should download edited file by default when edits exist', async () => {
|
||||||
const editedAsset = {
|
const editedAsset = AssetFactory.from()
|
||||||
...assetStub.withCropEdit,
|
.edit()
|
||||||
files: [
|
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||||
...assetStub.withCropEdit.files,
|
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||||
{
|
.build();
|
||||||
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',
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
new ImmichFileResponse({
|
||||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
path: editedAsset.files[3].path,
|
||||||
fileName: 'asset-id.jpg',
|
fileName: editedAsset.originalFileName,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
cacheControl: CacheControl.PrivateWithCache,
|
||||||
}),
|
}),
|
||||||
@@ -544,28 +535,19 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should download edited file when edited=true', async () => {
|
it('should download edited file when edited=true', async () => {
|
||||||
const editedAsset = {
|
const editedAsset = AssetFactory.from()
|
||||||
...assetStub.withCropEdit,
|
.edit()
|
||||||
files: [
|
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||||
...assetStub.withCropEdit.files,
|
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||||
{
|
.build();
|
||||||
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',
|
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
new ImmichFileResponse({
|
||||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
path: editedAsset.files[3].path,
|
||||||
fileName: 'asset-id.jpg',
|
fileName: editedAsset.originalFileName,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
cacheControl: CacheControl.PrivateWithCache,
|
||||||
}),
|
}),
|
||||||
@@ -579,7 +561,9 @@ describe(AssetMediaService.name, () => {
|
|||||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id]));
|
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||||
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path });
|
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({
|
new ImmichFileResponse({
|
||||||
path: fullsizeEdited.path,
|
path: fullsizeEdited.path,
|
||||||
fileName: editedAsset.originalFileName,
|
fileName: editedAsset.originalFileName,
|
||||||
@@ -590,25 +574,19 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should download original file when edited=false', async () => {
|
it('should download original file when edited=false', async () => {
|
||||||
const editedAsset = {
|
const editedAsset = AssetFactory.from()
|
||||||
...assetStub.withCropEdit,
|
.edit()
|
||||||
files: [
|
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||||
...assetStub.withCropEdit.files,
|
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||||
{
|
.build();
|
||||||
id: 'edited-file',
|
|
||||||
type: AssetFileType.FullSize,
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
|
||||||
isEdited: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
||||||
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
|
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({
|
new ImmichFileResponse({
|
||||||
path: '/original/path.jpg',
|
path: editedAsset.originalPath,
|
||||||
fileName: 'asset-id.jpg',
|
fileName: editedAsset.originalFileName,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
cacheControl: CacheControl.PrivateWithCache,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
|||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
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 () => {
|
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.stack.delete.mockResolvedValue();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
||||||
...assetStub.primaryImage,
|
...asset,
|
||||||
stack: {
|
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
|
||||||
id: 'stack-id',
|
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
|
||||||
primaryAssetId: assetStub.primaryImage.id,
|
|
||||||
assets: [{ id: 'one-asset' }],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
it('should delete a live photo', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
|||||||
import { DuplicateService } from 'src/services/duplicate.service';
|
import { DuplicateService } from 'src/services/duplicate.service';
|
||||||
import { SearchService } from 'src/services/search.service';
|
import { SearchService } from 'src/services/search.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -184,13 +183,13 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if asset is part of stack', async () => {
|
it('should skip if asset is part of stack', async () => {
|
||||||
const id = assetStub.primaryImage.id;
|
const asset = AssetFactory.from().stack().build();
|
||||||
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' });
|
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(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 () => {
|
it('should skip if asset is not visible', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MapService } from 'src/services/map.service';
|
import { MapService } from 'src/services/map.service';
|
||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
import { AlbumFactory } from 'test/factories/album.factory';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -16,36 +16,41 @@ describe(MapService.name, () => {
|
|||||||
|
|
||||||
describe('getMapMarkers', () => {
|
describe('getMapMarkers', () => {
|
||||||
it('should get geo information of assets', async () => {
|
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 = {
|
const marker = {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
lat: asset.exifInfo!.latitude!,
|
lat: asset.exifInfo.latitude!,
|
||||||
lon: asset.exifInfo!.longitude!,
|
lon: asset.exifInfo.longitude!,
|
||||||
city: asset.exifInfo!.city,
|
city: asset.exifInfo.city,
|
||||||
state: asset.exifInfo!.state,
|
state: asset.exifInfo.state,
|
||||||
country: asset.exifInfo!.country,
|
country: asset.exifInfo.country,
|
||||||
};
|
};
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
mocks.partner.getAll.mockResolvedValue([]);
|
||||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||||
|
|
||||||
const markers = await sut.getMapMarkers(authStub.user1, {});
|
const markers = await sut.getMapMarkers(auth, {});
|
||||||
|
|
||||||
expect(markers).toHaveLength(1);
|
expect(markers).toHaveLength(1);
|
||||||
expect(markers[0]).toEqual(marker);
|
expect(markers[0]).toEqual(marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include partner assets', async () => {
|
it('should include partner assets', async () => {
|
||||||
const partner = factory.partner();
|
const auth = AuthFactory.create();
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
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 = {
|
const marker = {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
lat: asset.exifInfo!.latitude!,
|
lat: asset.exifInfo.latitude!,
|
||||||
lon: asset.exifInfo!.longitude!,
|
lon: asset.exifInfo.longitude!,
|
||||||
city: asset.exifInfo!.city,
|
city: asset.exifInfo.city,
|
||||||
state: asset.exifInfo!.state,
|
state: asset.exifInfo.state,
|
||||||
country: asset.exifInfo!.country,
|
country: asset.exifInfo.country,
|
||||||
};
|
};
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||||
@@ -62,21 +67,24 @@ describe(MapService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include assets from shared albums', async () => {
|
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 = {
|
const marker = {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
lat: asset.exifInfo!.latitude!,
|
lat: asset.exifInfo.latitude!,
|
||||||
lon: asset.exifInfo!.longitude!,
|
lon: asset.exifInfo.longitude!,
|
||||||
city: asset.exifInfo!.city,
|
city: asset.exifInfo.city,
|
||||||
state: asset.exifInfo!.state,
|
state: asset.exifInfo.state,
|
||||||
country: asset.exifInfo!.country,
|
country: asset.exifInfo.country,
|
||||||
};
|
};
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
mocks.partner.getAll.mockResolvedValue([]);
|
||||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||||
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
|
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
|
||||||
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
|
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).toHaveLength(1);
|
||||||
expect(markers[0]).toEqual(marker);
|
expect(markers[0]).toEqual(marker);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
import { MediaService } from 'src/services/media.service';
|
import { MediaService } from 'src/services/media.service';
|
||||||
import { JobCounts, RawImageInfo } from 'src/types';
|
import { JobCounts, RawImageInfo } from 'src/types';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
|
||||||
import { faceStub } from 'test/fixtures/face.stub';
|
import { faceStub } from 'test/fixtures/face.stub';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { personStub, personThumbnailStub } from 'test/fixtures/person.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 () => {
|
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());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetEditThumbnailGeneration,
|
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 () => {
|
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.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());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
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 () => {
|
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());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
@@ -259,11 +261,11 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.AssetGenerateThumbnails,
|
name: JobName.AssetGenerateThumbnails,
|
||||||
data: { id: assetStub.withCropEdit.id },
|
data: { id: asset.id },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: JobName.AssetEditThumbnailGeneration,
|
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.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
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,
|
colorspace: Colorspace.P3,
|
||||||
orientation: undefined,
|
orientation: undefined,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
@@ -2193,7 +2195,7 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
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.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
||||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
import { tagStub } from 'test/fixtures/tag.stub';
|
import { tagStub } from 'test/fixtures/tag.stub';
|
||||||
@@ -1227,16 +1226,17 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.person.updateAll).not.toHaveBeenCalled();
|
expect(mocks.person.updateAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply metadata face tags creating new persons', async () => {
|
it('should apply metadata face tags creating new people', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
const asset = AssetFactory.create();
|
||||||
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ name: personStub.withName.name }),
|
expect.objectContaining({ name: personStub.withName.name }),
|
||||||
]);
|
]);
|
||||||
@@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: assetStub.primaryImage.id,
|
assetId: asset.id,
|
||||||
personId: 'random-uuid',
|
personId: 'random-uuid',
|
||||||
imageHeight: 100,
|
imageHeight: 100,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
@@ -1258,7 +1258,7 @@ describe(MetadataService.name, () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
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([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
@@ -1269,21 +1269,22 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should assign metadata face tags to existing persons', async () => {
|
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 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||||
expect(mocks.person.createAll).not.toHaveBeenCalled();
|
expect(mocks.person.createAll).not.toHaveBeenCalled();
|
||||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: assetStub.primaryImage.id,
|
assetId: asset.id,
|
||||||
personId: personStub.withName.id,
|
personId: personStub.withName.id,
|
||||||
imageHeight: 100,
|
imageHeight: 100,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
@@ -1353,16 +1354,17 @@ describe(MetadataService.name, () => {
|
|||||||
'should transform RegionInfo geometry according to exif orientation $description',
|
'should transform RegionInfo geometry according to exif orientation $description',
|
||||||
async ({ orientation, expected }) => {
|
async ({ orientation, expected }) => {
|
||||||
const { imgW, imgH, x1, x2, y1, y2 } = 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 } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
|
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, {
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
|
||||||
withHidden: true,
|
withHidden: true,
|
||||||
});
|
});
|
||||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
||||||
@@ -1372,7 +1374,7 @@ describe(MetadataService.name, () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: assetStub.primaryImage.id,
|
assetId: asset.id,
|
||||||
personId: 'random-uuid',
|
personId: 'random-uuid',
|
||||||
imageWidth: imgW,
|
imageWidth: imgW,
|
||||||
imageHeight: imgH,
|
imageHeight: imgH,
|
||||||
@@ -1386,7 +1388,7 @@ describe(MetadataService.name, () => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
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([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common';
|
|||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||||
import { SearchService } from 'src/services/search.service';
|
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 { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -64,16 +65,18 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('getExploreData', () => {
|
describe('getExploreData', () => {
|
||||||
it('should get assets by city and tag', async () => {
|
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({
|
mocks.asset.getAssetIdByCity.mockResolvedValue({
|
||||||
fieldName: 'exifInfo.city',
|
fieldName: 'exifInfo.city',
|
||||||
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
|
items: [{ value: 'city', data: asset.id }],
|
||||||
});
|
});
|
||||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
|
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
|
||||||
const expectedResponse = [
|
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
|
||||||
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await sut.getExploreData(authStub.user1);
|
const result = await sut.getExploreData(auth);
|
||||||
|
|
||||||
expect(result).toEqual(expectedResponse);
|
expect(result).toEqual(expectedResponse);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { StackService } from 'src/services/stack.service';
|
import { StackService } from 'src/services/stack.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
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 { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -20,12 +21,14 @@ describe(StackService.name, () => {
|
|||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
it('should search stacks', async () => {
|
it('should search stacks', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
const asset = AssetFactory.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({
|
expect(mocks.stack.search).toHaveBeenCalledWith({
|
||||||
ownerId: authStub.admin.user.id,
|
ownerId: auth.user.id,
|
||||||
primaryAssetId: asset.id,
|
primaryAssetId: asset.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -33,8 +36,10 @@ describe(StackService.name, () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should require asset.update permissions', async () => {
|
it('should require asset.update permissions', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.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,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,18 +48,22 @@ describe(StackService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create a stack', async () => {
|
it('should create a stack', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.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.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
|
||||||
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
mocks.stack.create.mockResolvedValue(stack);
|
||||||
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
|
|
||||||
id: 'stack-id',
|
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
|
||||||
|
id: stack.id,
|
||||||
primaryAssetId: primaryAsset.id,
|
primaryAssetId: primaryAsset.id,
|
||||||
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
|
||||||
stackId: 'stack-id',
|
stackId: stack.id,
|
||||||
userId: authStub.admin.user.id,
|
userId: auth.user.id,
|
||||||
});
|
});
|
||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -78,23 +87,26 @@ describe(StackService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get stack', async () => {
|
it('should get stack', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
|
||||||
|
|
||||||
await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
|
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||||
id: 'stack-id',
|
mocks.stack.getById.mockResolvedValue(stack);
|
||||||
|
|
||||||
|
await expect(sut.get(auth, stack.id)).resolves.toEqual({
|
||||||
|
id: stack.id,
|
||||||
primaryAssetId: primaryAsset.id,
|
primaryAssetId: primaryAsset.id,
|
||||||
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
||||||
});
|
});
|
||||||
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
|
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
|
||||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should require stack.update permissions', async () => {
|
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.getById).not.toHaveBeenCalled();
|
||||||
expect(mocks.stack.update).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 () => {
|
it('should fail if stack could not be found', async () => {
|
||||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
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.getById).toHaveBeenCalledWith('stack-id');
|
||||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
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 () => {
|
it('should fail if the provided primary asset id is not in the stack', async () => {
|
||||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
const auth = AuthFactory.create();
|
||||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
const stack = StackFactory.from().primaryAsset().asset().build();
|
||||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
|
||||||
|
|
||||||
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,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update stack', async () => {
|
it('should update stack', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
|
||||||
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
|
||||||
|
|
||||||
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');
|
await sut.update(auth, stack.id, { primaryAssetId: asset.id });
|
||||||
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', {
|
|
||||||
id: 'stack-id',
|
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||||
|
expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, {
|
||||||
|
id: stack.id,
|
||||||
primaryAssetId: asset.id,
|
primaryAssetId: asset.id,
|
||||||
});
|
});
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
|
||||||
stackId: 'stack-id',
|
stackId: stack.id,
|
||||||
userId: authStub.admin.user.id,
|
userId: auth.user.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
it('should require stack.delete permissions', async () => {
|
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.stack.delete).not.toHaveBeenCalled();
|
||||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete stack', async () => {
|
it('should delete stack', async () => {
|
||||||
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||||
mocks.stack.delete.mockResolvedValue();
|
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.stack.delete).toHaveBeenCalledWith('stack-id');
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', {
|
||||||
stackId: 'stack-id',
|
stackId: 'stack-id',
|
||||||
userId: authStub.admin.user.id,
|
userId: auth.user.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { defaults, SystemConfig } from 'src/config';
|
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 { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
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 { userStub } from 'test/fixtures/user.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { getForStorageTemplate } from 'test/mappers';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const motionAsset = assetStub.storageAsset({});
|
|
||||||
const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id });
|
|
||||||
|
|
||||||
describe(StorageTemplateService.name, () => {
|
describe(StorageTemplateService.name, () => {
|
||||||
let sut: StorageTemplateService;
|
let sut: StorageTemplateService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate single moving picture', async () => {
|
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);
|
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}`;
|
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||||
|
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
mocks.move.create.mockResolvedValueOnce({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use handlebar if condition for album', async () => {
|
it('should use handlebar if condition for album', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
const user = userStub.user1;
|
const asset = AssetFactory.from().owner(user).exif().build();
|
||||||
const album = AlbumFactory.from().asset().build();
|
const album = AlbumFactory.from().asset().build();
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
||||||
@@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
sut.onConfigInit({ newConfig: config });
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||||
|
|
||||||
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
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 () => {
|
it('should use handlebar else condition for album', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
const user = userStub.user1;
|
const asset = AssetFactory.from().owner(user).exif().build();
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
|
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
|
||||||
sut.onConfigInit({ newConfig: config });
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
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);
|
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
||||||
|
|
||||||
@@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle album startDate', async () => {
|
it('should handle album startDate', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
const user = userStub.user1;
|
const asset = AssetFactory.from().owner(user).exif().build();
|
||||||
const album = AlbumFactory.from().asset().build();
|
const album = AlbumFactory.from().asset().build();
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template =
|
config.storageTemplate.template =
|
||||||
@@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
sut.onConfigInit({ newConfig: config });
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValueOnce([
|
mocks.album.getMetadataForIds.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle else condition from album startDate', async () => {
|
it('should handle else condition from album startDate', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
const user = userStub.user1;
|
const asset = AssetFactory.from().owner(user).exif().build();
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template =
|
config.storageTemplate.template =
|
||||||
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
|
'{{#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 });
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
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);
|
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 () => {
|
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();
|
mocks.user.get.mockResolvedValue(user);
|
||||||
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
|
|
||||||
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
|
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.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
|
||||||
mocks.move.getByEntity.mockResolvedValue({
|
mocks.move.getByEntity.mockResolvedValue({
|
||||||
@@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
oldPath: asset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath: previousFailedNewPath,
|
newPath: previousFailedNewPath,
|
||||||
});
|
});
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||||
mocks.move.update.mockResolvedValue({
|
mocks.move.update.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
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 () => {
|
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 previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
|
||||||
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
||||||
|
|
||||||
@@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
oldPath: asset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath: previousFailedNewPath,
|
newPath: previousFailedNewPath,
|
||||||
});
|
});
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||||
mocks.move.update.mockResolvedValue({
|
mocks.move.update.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
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 () => {
|
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 user = UserFactory.create();
|
||||||
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
|
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.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
|
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
|
||||||
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
|
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: testAsset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.Original,
|
pathType: AssetPathType.Original,
|
||||||
oldPath: testAsset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath,
|
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.checkFileExists).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath);
|
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath);
|
||||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
entityId: testAsset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.Original,
|
pathType: AssetPathType.Original,
|
||||||
oldPath: testAsset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath,
|
newPath,
|
||||||
});
|
});
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath);
|
expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath);
|
||||||
expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath);
|
expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const testAsset = assetStub.storageAsset();
|
const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build();
|
||||||
|
|
||||||
it.each`
|
it.each`
|
||||||
failedPathChecksum | failedPathSize | reason
|
failedPathChecksum | failedPathSize | reason
|
||||||
${testAsset.checksum} | ${500} | ${'file size'}
|
${testAsset.checksum} | ${500} | ${'file size'}
|
||||||
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'}
|
${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',
|
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
|
||||||
async ({ failedPathChecksum, failedPathSize }) => {
|
async ({ failedPathChecksum, failedPathSize }) => {
|
||||||
@@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
oldPath: testAsset.originalPath,
|
oldPath: testAsset.originalPath,
|
||||||
newPath: previousFailedNewPath,
|
newPath: previousFailedNewPath,
|
||||||
});
|
});
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset));
|
||||||
mocks.move.update.mockResolvedValue({
|
mocks.move.update.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: testAsset.id,
|
entityId: testAsset.id,
|
||||||
@@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an asset with a duplicate destination', async () => {
|
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 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');
|
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.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip when an asset already matches the template', async () => {
|
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]);
|
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
@@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip when an asset is probably a duplicate', async () => {
|
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]);
|
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
@@ -471,10 +519,15 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should move an asset', async () => {
|
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 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}`;
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use the user storage label', async () => {
|
it('should use the user storage label', async () => {
|
||||||
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
const user = UserFactory.create({ storageLabel: 'label-1' });
|
||||||
const asset = assetStub.storageAsset({ ownerId: user.id });
|
const asset = AssetFactory.from({
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
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.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
asset.originalPath,
|
||||||
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
|
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
|
||||||
);
|
);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
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 () => {
|
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 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}`;
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||||
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
mocks.move.create.mockResolvedValue({
|
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 () => {
|
it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
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.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
@@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
asset.originalPath,
|
||||||
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.storage.copyFile).toHaveBeenCalledWith(
|
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
asset.originalPath,
|
||||||
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.storage.stat).toHaveBeenCalledWith(
|
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();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not update the database if the move fails', async () => {
|
it('should not update the database if the move fails', async () => {
|
||||||
const asset = assetStub.storageAsset();
|
const user = UserFactory.create();
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
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.rename.mockRejectedValue(new Error('Read only system'));
|
||||||
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system'));
|
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system'));
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
@@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => {
|
|||||||
oldPath: asset.originalPath,
|
oldPath: asset.originalPath,
|
||||||
newPath: '',
|
newPath: '',
|
||||||
});
|
});
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
|
|
||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
asset.originalPath,
|
||||||
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();
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate live photo motion video alongside the still image', async () => {
|
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}`;
|
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.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
mocks.move.create.mockResolvedValueOnce({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => {
|
|||||||
|
|
||||||
describe('file rename correctness', () => {
|
describe('file rename correctness', () => {
|
||||||
it('should not create double extensions when filename has lower extension', async () => {
|
it('should not create double extensions when filename has lower extension', async () => {
|
||||||
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
const user = UserFactory.create({ storageLabel: 'label-1' });
|
||||||
const asset = assetStub.storageAsset({
|
const asset = AssetFactory.from({
|
||||||
ownerId: user.id,
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||||
originalFileName: '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.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not create double extensions when filename has uppercase extension', async () => {
|
it('should not create double extensions when filename has uppercase extension', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = AssetFactory.from({
|
||||||
ownerId: user.id,
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
||||||
originalFileName: '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.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = AssetFactory.from({
|
||||||
ownerId: user.id,
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||||
originalFileName: '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.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const asset = assetStub.storageAsset({
|
const asset = AssetFactory.from({
|
||||||
ownerId: user.id,
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
||||||
originalFileName: '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.user.getList.mockResolvedValue([user]);
|
||||||
mocks.move.create.mockResolvedValue({
|
mocks.move.create.mockResolvedValue({
|
||||||
id: '123',
|
id: '123',
|
||||||
|
|||||||
@@ -1,60 +1,11 @@
|
|||||||
import { createAdapter } from '@socket.io/redis-adapter';
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Server as SocketIO } from 'socket.io';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
||||||
import { StorageFolder } from 'src/enum';
|
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';
|
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(
|
export async function createMaintenanceLoginUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
auth: MaintenanceAuthDto,
|
auth: MaintenanceAuthDto,
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
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 { configureExpress, configureTelemetry } from 'src/app.common';
|
||||||
import { ApiModule } from 'src/app.module';
|
import { ApiModule } from 'src/app.module';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ApiService } from 'src/services/api.service';
|
import { ApiService } from 'src/services/api.service';
|
||||||
import { isStartUpError } from 'src/utils/misc';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
async function bootstrap() {
|
export async function bootstrap() {
|
||||||
process.title = 'immich-api';
|
process.title = 'immich-api';
|
||||||
|
|
||||||
|
const { inspectorPort } = workerData ?? {};
|
||||||
|
if (inspectorPort) {
|
||||||
|
inspector.open(inspectorPort, '0.0.0.0', false);
|
||||||
|
}
|
||||||
|
|
||||||
configureTelemetry();
|
configureTelemetry();
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
||||||
@@ -19,10 +26,12 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
if (!isMainThread || process.send) {
|
||||||
if (!isStartUpError(error)) {
|
bootstrap().catch((error) => {
|
||||||
console.error(error);
|
if (!isStartUpError(error)) {
|
||||||
}
|
console.error(error);
|
||||||
// eslint-disable-next-line unicorn/no-process-exit
|
}
|
||||||
process.exit(1);
|
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
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 { configureExpress, configureTelemetry } from 'src/app.common';
|
||||||
import { MaintenanceModule } from 'src/app.module';
|
import { MaintenanceModule } from 'src/app.module';
|
||||||
|
import { SocketIoAdapter } from 'src/enum';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { isStartUpError } from 'src/utils/misc';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
async function bootstrap() {
|
export async function bootstrap() {
|
||||||
process.title = 'immich-maintenance';
|
process.title = 'immich-maintenance';
|
||||||
|
|
||||||
|
const { inspectorPort } = workerData ?? {};
|
||||||
|
if (inspectorPort) {
|
||||||
|
inspector.open(inspectorPort, '0.0.0.0', false);
|
||||||
|
}
|
||||||
|
|
||||||
configureTelemetry();
|
configureTelemetry();
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
||||||
@@ -16,13 +25,18 @@ async function bootstrap() {
|
|||||||
void configureExpress(app, {
|
void configureExpress(app, {
|
||||||
permitSwaggerWrite: false,
|
permitSwaggerWrite: false,
|
||||||
ssr: MaintenanceWorkerService,
|
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 (!isMainThread) {
|
||||||
if (!isStartUpError(error)) {
|
bootstrap().catch((error) => {
|
||||||
console.error(error);
|
if (!isStartUpError(error)) {
|
||||||
}
|
console.error(error);
|
||||||
// eslint-disable-next-line unicorn/no-process-exit
|
}
|
||||||
process.exit(1);
|
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
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 { MicroservicesModule } from 'src/app.module';
|
||||||
import { serverVersion } from 'src/constants';
|
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 { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.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';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
|
const { inspectorPort } = workerData ?? {};
|
||||||
|
if (inspectorPort) {
|
||||||
|
inspector.open(inspectorPort, '0.0.0.0', false);
|
||||||
|
}
|
||||||
|
|
||||||
const { telemetry } = new ConfigRepository().getEnv();
|
const { telemetry } = new ConfigRepository().getEnv();
|
||||||
if (telemetry.metrics.size > 0) {
|
if (telemetry.metrics.size > 0) {
|
||||||
bootstrapTelemetry(telemetry.microservicesPort);
|
bootstrapTelemetry(telemetry.microservicesPort);
|
||||||
@@ -24,7 +30,7 @@ export async function bootstrap() {
|
|||||||
|
|
||||||
logger.setContext('Bootstrap');
|
logger.setContext('Bootstrap');
|
||||||
app.useLogger(logger);
|
app.useLogger(logger);
|
||||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
app.useWebSocketAdapter(await createWebSocketAdapter(app));
|
||||||
|
|
||||||
await (host ? app.listen(0, host) : app.listen(0));
|
await (host ? app.listen(0, host) : app.listen(0));
|
||||||
|
|
||||||
|
|||||||
47
server/test/factories/asset-face.factory.ts
Normal file
47
server/test/factories/asset-face.factory.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Selectable } from 'kysely';
|
import { Selectable } from 'kysely';
|
||||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
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 { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
|
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
|
||||||
import { AssetExifFactory } from 'test/factories/asset-exif.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 { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||||
import { build } from 'test/factories/builder.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 { UserFactory } from 'test/factories/user.factory';
|
||||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
@@ -15,7 +26,8 @@ export class AssetFactory {
|
|||||||
#assetExif?: AssetExifFactory;
|
#assetExif?: AssetExifFactory;
|
||||||
#files: AssetFileFactory[] = [];
|
#files: AssetFileFactory[] = [];
|
||||||
#edits: AssetEditFactory[] = [];
|
#edits: AssetEditFactory[] = [];
|
||||||
#faces: Selectable<AssetFaceTable>[] = [];
|
#faces: AssetFaceFactory[] = [];
|
||||||
|
#stack?: Selectable<StackTable> & { assets: Selectable<AssetTable>[]; primaryAsset: Selectable<AssetTable> };
|
||||||
|
|
||||||
private constructor(private readonly value: Selectable<AssetTable>) {
|
private constructor(private readonly value: Selectable<AssetTable>) {
|
||||||
value.ownerId ??= newUuid();
|
value.ownerId ??= newUuid();
|
||||||
@@ -83,8 +95,8 @@ export class AssetFactory {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
face(dto: Selectable<AssetFaceTable>) {
|
face(dto: AssetFaceLike = {}, builder?: FactoryBuilder<AssetFaceFactory>) {
|
||||||
this.#faces.push(dto);
|
this.#faces.push(build(AssetFaceFactory.from(dto), builder));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +129,12 @@ export class AssetFactory {
|
|||||||
return this;
|
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() {
|
build() {
|
||||||
const exif = this.#assetExif?.build();
|
const exif = this.#assetExif?.build();
|
||||||
|
|
||||||
@@ -126,8 +144,9 @@ export class AssetFactory {
|
|||||||
exifInfo: exif as NonNullable<typeof exif>,
|
exifInfo: exif as NonNullable<typeof exif>,
|
||||||
files: this.#files.map((file) => file.build()),
|
files: this.#files.map((file) => file.build()),
|
||||||
edits: this.#edits.map((edit) => edit.build()),
|
edits: this.#edits.map((edit) => edit.build()),
|
||||||
faces: this.#faces,
|
faces: this.#faces.map((face) => face.build()),
|
||||||
stack: null,
|
stack: this.#stack ?? null,
|
||||||
|
tags: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
server/test/factories/person.factory.ts
Normal file
34
server/test/factories/person.factory.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
52
server/test/factories/stack.factory.ts
Normal file
52
server/test/factories/stack.factory.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
|||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.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 { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.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 { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||||
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
|
|
||||||
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
|
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 AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
|
||||||
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
|
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
|
||||||
export type UserLike = Partial<Selectable<UserTable>>;
|
export type UserLike = Partial<Selectable<UserTable>>;
|
||||||
|
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||||
|
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||||
|
export type StackLike = Partial<Selectable<StackTable>>;
|
||||||
|
|||||||
298
server/test/fixtures/asset.stub.ts
vendored
298
server/test/fixtures/asset.stub.ts
vendored
@@ -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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
18
server/test/fixtures/person.stub.ts
vendored
18
server/test/fixtures/person.stub.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import { AssetType } from 'src/enum';
|
import { AssetFileType, AssetType } from 'src/enum';
|
||||||
import { previewFile } from 'test/fixtures/asset.stub';
|
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
|
||||||
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
||||||
@@ -179,7 +179,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
newThumbnailMiddle: Object.freeze({
|
newThumbnailMiddle: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -192,7 +192,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
newThumbnailEnd: Object.freeze({
|
newThumbnailEnd: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -205,7 +205,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
rawEmbeddedThumbnail: Object.freeze({
|
rawEmbeddedThumbnail: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -218,7 +218,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.dng',
|
originalPath: '/original/path.dng',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
negativeCoordinate: Object.freeze({
|
negativeCoordinate: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -231,7 +231,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
overflowingCoordinate: Object.freeze({
|
overflowingCoordinate: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -244,7 +244,7 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/original/path.jpg',
|
originalPath: '/original/path.jpg',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
videoThumbnail: Object.freeze({
|
videoThumbnail: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
@@ -257,6 +257,6 @@ export const personThumbnailStub = {
|
|||||||
type: AssetType.Video,
|
type: AssetType.Video,
|
||||||
originalPath: '/original/path.mp4',
|
originalPath: '/original/path.mp4',
|
||||||
exifOrientation: '1',
|
exifOrientation: '1',
|
||||||
previewPath: previewFile.path,
|
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
22
server/test/mappers.ts
Normal file
22
server/test/mappers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ConfigRepository, EnvData } from 'src/repositories/config.repository';
|
||||||
import { RepositoryInterface } from 'src/types';
|
import { RepositoryInterface } from 'src/types';
|
||||||
import { Mocked, vitest } from 'vitest';
|
import { Mocked, vitest } from 'vitest';
|
||||||
@@ -99,6 +99,10 @@ const envData: EnvData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
socketIo: {
|
||||||
|
adapter: SocketIoAdapter.Postgres,
|
||||||
|
},
|
||||||
|
|
||||||
noColor: false,
|
noColor: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
? 'object-contain'
|
? 'object-contain'
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
: slideshowLookCssMapping[$slideshowLook]} checkerboard"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||||
@@ -259,4 +259,8 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -443,54 +443,50 @@
|
|||||||
assetInteraction.clearAssetSelectionCandidates();
|
assetInteraction.clearAssetSelectionCandidates();
|
||||||
|
|
||||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||||
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
const startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||||
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
const endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||||
|
|
||||||
if (startBucket === null || endBucket === null) {
|
if (!startBucket || !endBucket) {
|
||||||
return;
|
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)
|
// Select/deselect assets in range (start,end)
|
||||||
let started = false;
|
for (let index = rangeStartIndex + 1; index < rangeEndIndex; index++) {
|
||||||
for (const monthGroup of timelineManager.months) {
|
const monthGroup = monthGroups[index];
|
||||||
if (monthGroup === endBucket) {
|
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||||
break;
|
for (const monthAsset of monthGroup.assetsIterator()) {
|
||||||
}
|
if (deselect) {
|
||||||
if (started) {
|
assetInteraction.removeAssetFromMultiselectGroup(monthAsset.id);
|
||||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
} else {
|
||||||
for (const asset of monthGroup.assetsIterator()) {
|
handleSelectAsset(monthAsset);
|
||||||
if (deselect) {
|
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
|
||||||
} else {
|
|
||||||
handleSelectAsset(asset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (monthGroup === startBucket) {
|
|
||||||
started = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update date group selection in range [start,end]
|
// Update date group selection in range [start,end]
|
||||||
started = false;
|
for (let index = rangeStartIndex; index <= rangeEndIndex; index++) {
|
||||||
for (const monthGroup of timelineManager.months) {
|
const monthGroup = monthGroups[index];
|
||||||
if (monthGroup === startBucket) {
|
|
||||||
started = true;
|
// Split month group into day groups and check each group
|
||||||
}
|
for (const dayGroup of monthGroup.dayGroups) {
|
||||||
if (started) {
|
const dayGroupTitle = dayGroup.groupTitle;
|
||||||
// Split month group into day groups and check each group
|
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||||
for (const dayGroup of monthGroup.dayGroups) {
|
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||||
const dayGroupTitle = dayGroup.groupTitle;
|
} else {
|
||||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
|
||||||
} else {
|
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (monthGroup === endBucket) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,8 +340,8 @@ export const langs: Lang[] = [
|
|||||||
{
|
{
|
||||||
name: 'Chinese (Simplified)',
|
name: 'Chinese (Simplified)',
|
||||||
code: 'zh-CN',
|
code: 'zh-CN',
|
||||||
weblateCode: 'zh_SIMPLIFIED',
|
weblateCode: 'zh_Hans',
|
||||||
loader: () => import('$i18n/zh_SIMPLIFIED.json'),
|
loader: () => import('$i18n/zh_Hans.json'),
|
||||||
},
|
},
|
||||||
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user