mirror of
https://github.com/immich-app/immich.git
synced 2026-03-13 22:06:53 -07:00
Compare commits
11 Commits
feat/pg-qu
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebc110603 | ||
|
|
2fb9f84b56 | ||
|
|
434ded92f5 | ||
|
|
bc7a1c838c | ||
|
|
7cb355279e | ||
|
|
ecb09501a5 | ||
|
|
34eb2e1410 | ||
|
|
2d6580acd8 | ||
|
|
9aa3fe82c1 | ||
|
|
66733eb4c0 | ||
|
|
e5156df4f1 |
@@ -180,18 +180,49 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
||||
}
|
||||
|
||||
let multiBar: MultiBar | undefined;
|
||||
let totalSize = 0;
|
||||
const statsMap = new Map<string, Stats>();
|
||||
|
||||
// Calculate total size first
|
||||
for (const filepath of files) {
|
||||
const stats = await stat(filepath);
|
||||
statsMap.set(filepath, stats);
|
||||
totalSize += stats.size;
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
multiBar = new MultiBar(
|
||||
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
{
|
||||
format: '{message} | {bar} | {percentage}% | ETA: {eta_formatted} | {value}/{total}',
|
||||
formatValue: (v: number, options, type) => {
|
||||
// Don't format percentage
|
||||
if (type === 'percentage') {
|
||||
return v.toString();
|
||||
}
|
||||
return byteSize(v).toString();
|
||||
},
|
||||
etaBuffer: 100, // Increase samples for ETA calculation
|
||||
},
|
||||
Presets.shades_classic,
|
||||
);
|
||||
|
||||
// Ensure we restore cursor on interrupt
|
||||
process.on('SIGINT', () => {
|
||||
if (multiBar) {
|
||||
multiBar.stop();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
} else {
|
||||
console.log(`Received ${files.length} files, hashing...`);
|
||||
console.log(`Received ${files.length} files (${byteSize(totalSize)}), hashing...`);
|
||||
}
|
||||
|
||||
const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' });
|
||||
const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' });
|
||||
const hashProgressBar = multiBar?.create(totalSize, 0, {
|
||||
message: 'Hashing files ',
|
||||
});
|
||||
const checkProgressBar = multiBar?.create(totalSize, 0, {
|
||||
message: 'Checking for duplicates',
|
||||
});
|
||||
|
||||
const newFiles: string[] = [];
|
||||
const duplicates: Asset[] = [];
|
||||
@@ -211,7 +242,13 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
||||
}
|
||||
}
|
||||
|
||||
checkProgressBar?.increment(assets.length);
|
||||
// Update progress based on total size of processed files
|
||||
let processedSize = 0;
|
||||
for (const asset of assets) {
|
||||
const stats = statsMap.get(asset.id);
|
||||
processedSize += stats?.size || 0;
|
||||
}
|
||||
checkProgressBar?.increment(processedSize);
|
||||
},
|
||||
{ concurrency, retry: 3 },
|
||||
);
|
||||
@@ -221,6 +258,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
||||
|
||||
const queue = new Queue<string, AssetBulkUploadCheckItem[]>(
|
||||
async (filepath: string): Promise<AssetBulkUploadCheckItem[]> => {
|
||||
const stats = statsMap.get(filepath);
|
||||
if (!stats) {
|
||||
throw new Error(`Stats not found for ${filepath}`);
|
||||
}
|
||||
const dto = { id: filepath, checksum: await sha1(filepath) };
|
||||
|
||||
results.push(dto);
|
||||
@@ -231,7 +272,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
||||
void checkBulkUploadQueue.push(batch);
|
||||
}
|
||||
|
||||
hashProgressBar?.increment();
|
||||
hashProgressBar?.increment(stats.size);
|
||||
return results;
|
||||
},
|
||||
{ concurrency, retry: 3 },
|
||||
|
||||
12
i18n/ca.json
12
i18n/ca.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Cercar treballs…",
|
||||
"send_welcome_email": "Enviar correu electrònic de benvinguda",
|
||||
"server_external_domain_settings": "Domini extern",
|
||||
"server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://",
|
||||
"server_external_domain_settings_description": "Domini utilitzat per a enllaços externs",
|
||||
"server_public_users": "Usuaris públics",
|
||||
"server_public_users_description": "Tots els usuaris (nom i correu electrònic) apareixen a la llista a l'afegir un usuari als àlbums compartits. Si es desactiva, la llista només serà disponible pels usuaris administradors.",
|
||||
"server_settings": "Configuració del servidor",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Color",
|
||||
"color_theme": "Tema de color",
|
||||
"command": "Ordre",
|
||||
"command_palette_prompt": "Trobar ràpidament pàgines, accions o comandes",
|
||||
"command_palette_to_close": "per a tancar",
|
||||
"command_palette_to_navigate": "per a introduir",
|
||||
"command_palette_to_select": "per a seleccionar",
|
||||
"command_palette_to_show_all": "per a mostrar-ho tot",
|
||||
"comment_deleted": "Comentari esborrat",
|
||||
"comment_options": "Opcions de comentari",
|
||||
"comments_and_likes": "Comentaris i agradaments",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "PERSONES",
|
||||
"exif_bottom_sheet_person_add_person": "Afegir nom",
|
||||
"exit_slideshow": "Surt de la presentació de diapositives",
|
||||
"expand": "Ampliar-ho",
|
||||
"expand_all": "Ampliar-ho tot",
|
||||
"experimental_settings_new_asset_list_subtitle": "Treball en curs",
|
||||
"experimental_settings_new_asset_list_title": "Habilita la graella de fotos experimental",
|
||||
@@ -1532,7 +1538,7 @@
|
||||
"mobile_app_download_onboarding_note": "Descarregar la App de mòbil fent servir les seguents opcions",
|
||||
"model": "Model",
|
||||
"month": "Mes",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"monthly_title_text_date_format": "MMMM a",
|
||||
"more": "Més",
|
||||
"move": "Moure",
|
||||
"move_down": "Moure cap avall",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "En línia",
|
||||
"only_favorites": "Només preferits",
|
||||
"open": "Obrir",
|
||||
"open_calendar": "Obrir el calendari",
|
||||
"open_in_map_view": "Obrir a la vista del mapa",
|
||||
"open_in_openstreetmap": "Obre a OpenStreetMap",
|
||||
"open_the_search_filters": "Obriu els filtres de cerca",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Suport",
|
||||
"support_and_feedback": "Suport i comentaris",
|
||||
"support_third_party_description": "La vostra instal·lació immich la va empaquetar un tercer. Els problemes que experimenteu poden ser causats per aquest paquet així que, si us plau, plantegeu els poblemes amb ells en primer lloc mitjançant els enllaços següents.",
|
||||
"supporter": "Contribuïdor",
|
||||
"swap_merge_direction": "Canvia la direcció d'unió",
|
||||
"sync": "Sincronitza",
|
||||
"sync_albums": "Sincronitzar àlbums",
|
||||
|
||||
10
i18n/cs.json
10
i18n/cs.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Hledat úlohy…",
|
||||
"send_welcome_email": "Odeslat uvítací e-mail",
|
||||
"server_external_domain_settings": "Externí doména",
|
||||
"server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://",
|
||||
"server_external_domain_settings_description": "Doména používaná pro externí odkazy",
|
||||
"server_public_users": "Veřejní uživatelé",
|
||||
"server_public_users_description": "Všichni uživatelé (jméno a e-mail) jsou uvedeni při přidávání uživatele do sdílených alb. Pokud je tato funkce vypnuta, bude seznam uživatelů dostupný pouze uživatelům z řad správců.",
|
||||
"server_settings": "Server",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Barva",
|
||||
"color_theme": "Barevný motiv",
|
||||
"command": "Příkaz",
|
||||
"command_palette_prompt": "Rychlé vyhledávání stránek, akcí nebo příkazů",
|
||||
"command_palette_to_close": "zavřít",
|
||||
"command_palette_to_navigate": "vstoupit",
|
||||
"command_palette_to_select": "vybrat",
|
||||
"command_palette_to_show_all": "zobrazit vše",
|
||||
"comment_deleted": "Komentář odstraněn",
|
||||
"comment_options": "Možnosti komentáře",
|
||||
"comments_and_likes": "Komentáře a lajky",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "LIDÉ",
|
||||
"exif_bottom_sheet_person_add_person": "Přidat jméno",
|
||||
"exit_slideshow": "Ukončit prezentaci",
|
||||
"expand": "Rozbalit",
|
||||
"expand_all": "Rozbalit vše",
|
||||
"experimental_settings_new_asset_list_subtitle": "Zpracovávám",
|
||||
"experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Online",
|
||||
"only_favorites": "Pouze oblíbené",
|
||||
"open": "Otevřít",
|
||||
"open_calendar": "Otevřít kalendář",
|
||||
"open_in_map_view": "Otevřít v zobrazení mapy",
|
||||
"open_in_openstreetmap": "Otevřít v OpenStreetMap",
|
||||
"open_the_search_filters": "Otevřít vyhledávací filtry",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Podpora",
|
||||
"support_and_feedback": "Podpora a zpětná vazba",
|
||||
"support_third_party_description": "Vaše Immich instalace byla připravena třetí stranou. Problémy, které se u vás vyskytly, mohou být způsobeny tímto balíčkem, proto se na ně obraťte v první řadě pomocí níže uvedených odkazů.",
|
||||
"supporter": "Podporovatel",
|
||||
"swap_merge_direction": "Obrátit směr sloučení",
|
||||
"sync": "Synchronizovat",
|
||||
"sync_albums": "Synchronizovat alba",
|
||||
|
||||
16
i18n/es.json
16
i18n/es.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Buscar trabajos…",
|
||||
"send_welcome_email": "Enviar correo de bienvenida",
|
||||
"server_external_domain_settings": "Dominio externo",
|
||||
"server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://",
|
||||
"server_external_domain_settings_description": "Dominio usado para enlaces externos",
|
||||
"server_public_users": "Usuarios públicos",
|
||||
"server_public_users_description": "Cuando se añade un usuario a los álbumes compartidos, todos los usuarios aparecen en una lista con su nombre y su correo electrónico. Si deshabilita esta opción, solo los administradores podrán ver la lista de usuarios.",
|
||||
"server_settings": "Configuración del servidor",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Color",
|
||||
"color_theme": "Color del tema",
|
||||
"command": "Comando",
|
||||
"command_palette_prompt": "Encuentra rápidamente páginas, acciones o comandos",
|
||||
"command_palette_to_close": "para cerrar",
|
||||
"command_palette_to_navigate": "para entrar",
|
||||
"command_palette_to_select": "para seleccionar",
|
||||
"command_palette_to_show_all": "para mostrar todo",
|
||||
"comment_deleted": "Comentario borrado",
|
||||
"comment_options": "Opciones de comentarios",
|
||||
"comments_and_likes": "Comentarios y me gusta",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "PERSONAS",
|
||||
"exif_bottom_sheet_person_add_person": "Añadir nombre",
|
||||
"exit_slideshow": "Salir de la presentación",
|
||||
"expand": "Expandir",
|
||||
"expand_all": "Expandir todo",
|
||||
"experimental_settings_new_asset_list_subtitle": "Trabajo en progreso",
|
||||
"experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "En línea",
|
||||
"only_favorites": "Solo favoritos",
|
||||
"open": "Abierto",
|
||||
"open_calendar": "Abrir calendario",
|
||||
"open_in_map_view": "Abrir en la vista del mapa",
|
||||
"open_in_openstreetmap": "Abrir en OpenStreetMap",
|
||||
"open_the_search_filters": "Abre los filtros de búsqueda",
|
||||
@@ -1764,7 +1771,7 @@
|
||||
"profile_picture_set": "Conjunto de imágenes de perfil.",
|
||||
"public_album": "Álbum público",
|
||||
"public_share": "Compartir públicamente",
|
||||
"purchase_account_info": "Seguidor",
|
||||
"purchase_account_info": "Colaborador",
|
||||
"purchase_activated_subtitle": "Gracias por apoyar a Immich y al software de código abierto",
|
||||
"purchase_activated_time": "Activado el {date}",
|
||||
"purchase_activated_title": "Su clave ha sido activada correctamente",
|
||||
@@ -1777,7 +1784,7 @@
|
||||
"purchase_button_select": "Seleccionar",
|
||||
"purchase_failed_activation": "¡Error al activar! ¡Por favor, revisa tu correo electrónico para obtener la clave del producto correcta!",
|
||||
"purchase_individual_description_1": "Para un usuario",
|
||||
"purchase_individual_description_2": "Estado de soporte",
|
||||
"purchase_individual_description_2": "Estatus de colaborador",
|
||||
"purchase_individual_title": "Individual",
|
||||
"purchase_input_suggestion": "¿Tiene una clave de producto? Introdúzcala a continuación",
|
||||
"purchase_license_subtitle": "Compre Immich para apoyar el desarrollo continuo del servicio",
|
||||
@@ -1793,7 +1800,7 @@
|
||||
"purchase_remove_server_product_key": "Eliminar la clave de producto del servidor",
|
||||
"purchase_remove_server_product_key_prompt": "¿Está seguro de que desea eliminar la clave de producto del servidor?",
|
||||
"purchase_server_description_1": "Para todo el servidor",
|
||||
"purchase_server_description_2": "Estado del soporte",
|
||||
"purchase_server_description_2": "Estatus de colaborador",
|
||||
"purchase_server_title": "Servidor",
|
||||
"purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador",
|
||||
"query_asset_id": "Consultar ID de recurso",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Soporte",
|
||||
"support_and_feedback": "Soporte y comentarios",
|
||||
"support_third_party_description": "Esta instalación de Immich fue empaquetada por un tercero. Los problemas actuales pueden ser ocasionados por ese paquete; por favor, discuta sus inconvenientes con el empaquetador antes de usar los enlaces de abajo.",
|
||||
"supporter": "Colaborador",
|
||||
"swap_merge_direction": "Alternar dirección de mezcla",
|
||||
"sync": "Sincronizar",
|
||||
"sync_albums": "Sincronizar álbumes",
|
||||
|
||||
10
i18n/et.json
10
i18n/et.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Otsi töödet…",
|
||||
"send_welcome_email": "Saada tervituskiri",
|
||||
"server_external_domain_settings": "Väline domeen",
|
||||
"server_external_domain_settings_description": "Domeen avalikult jagatud linkide jaoks, k.a. http(s)://",
|
||||
"server_external_domain_settings_description": "Domeen väliste linkide jaoks",
|
||||
"server_public_users": "Avalikud kasutajad",
|
||||
"server_public_users_description": "Kasutaja jagatud albumisse lisamisel kuvatakse kõiki kasutajaid (nime ja e-posti aadressiga). Kui keelatud, kuvatakse kasutajate nimekirja ainult administraatoritele.",
|
||||
"server_settings": "Serveri seaded",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Värv",
|
||||
"color_theme": "Värviteema",
|
||||
"command": "Käsk",
|
||||
"command_palette_prompt": "Leia kiirelt lehti, tegevusi või käske",
|
||||
"command_palette_to_close": "sulge",
|
||||
"command_palette_to_navigate": "sisene",
|
||||
"command_palette_to_select": "vali",
|
||||
"command_palette_to_show_all": "näita kõiki",
|
||||
"comment_deleted": "Kommentaar kustutatud",
|
||||
"comment_options": "Kommentaari valikud",
|
||||
"comments_and_likes": "Kommentaarid ja meeldimised",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "ISIKUD",
|
||||
"exif_bottom_sheet_person_add_person": "Lisa nimi",
|
||||
"exit_slideshow": "Sulge slaidiesitlus",
|
||||
"expand": "Laienda",
|
||||
"expand_all": "Näita kõik",
|
||||
"experimental_settings_new_asset_list_subtitle": "Töös",
|
||||
"experimental_settings_new_asset_list_title": "Luba eksperimentaalne fotoruudistik",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Ühendatud",
|
||||
"only_favorites": "Ainult lemmikud",
|
||||
"open": "Ava",
|
||||
"open_calendar": "Ava kalender",
|
||||
"open_in_map_view": "Ava kaardi vaates",
|
||||
"open_in_openstreetmap": "Ava OpenStreetMap",
|
||||
"open_the_search_filters": "Ava otsingufiltrid",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Tugi",
|
||||
"support_and_feedback": "Tugi ja tagasiside",
|
||||
"support_third_party_description": "Sinu Immich'i install on kolmanda osapoole pakendatud. Probleemid, mida täheldad, võivad olla põhjustatud selle pakendamise poolt, seega võta esmajärjekorras nendega ühendust, kasutades allolevaid linke.",
|
||||
"supporter": "Toetaja",
|
||||
"swap_merge_direction": "Muuda ühendamise suunda",
|
||||
"sync": "Sünkrooni",
|
||||
"sync_albums": "Sünkrooni albumid",
|
||||
|
||||
10
i18n/fr.json
10
i18n/fr.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Recherche des tâches…",
|
||||
"send_welcome_email": "Envoyer un courriel de bienvenue",
|
||||
"server_external_domain_settings": "Domaine externe",
|
||||
"server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://",
|
||||
"server_external_domain_settings_description": "Nom de domaine utilisé pour les liens externes",
|
||||
"server_public_users": "Utilisateurs publics",
|
||||
"server_public_users_description": "Tous les utilisateurs (nom et courriel) sont listés lors de l'ajout d'un utilisateur à des albums partagés. Quand cela est désactivé, la liste des utilisateurs est uniquement disponible pour les comptes administrateurs.",
|
||||
"server_settings": "Paramètres du serveur",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Couleur",
|
||||
"color_theme": "Thème de couleur",
|
||||
"command": "Commande",
|
||||
"command_palette_prompt": "Trouver rapidement des pages, actions ou commandes",
|
||||
"command_palette_to_close": "pour fermer",
|
||||
"command_palette_to_navigate": "pour entrer",
|
||||
"command_palette_to_select": "pour sélectionner",
|
||||
"command_palette_to_show_all": "pour tout afficher",
|
||||
"comment_deleted": "Commentaire supprimé",
|
||||
"comment_options": "Options des commentaires",
|
||||
"comments_and_likes": "Commentaires et \"J'aime\"",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "PERSONNES",
|
||||
"exif_bottom_sheet_person_add_person": "Ajouter un nom",
|
||||
"exit_slideshow": "Quitter le diaporama",
|
||||
"expand": "Développer",
|
||||
"expand_all": "Tout développer",
|
||||
"experimental_settings_new_asset_list_subtitle": "En cours de développement",
|
||||
"experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "En ligne",
|
||||
"only_favorites": "Uniquement les favoris",
|
||||
"open": "Ouvrir",
|
||||
"open_calendar": "Ouvrir le calendrier",
|
||||
"open_in_map_view": "Montrer sur la carte",
|
||||
"open_in_openstreetmap": "Ouvrir dans OpenStreetMap",
|
||||
"open_the_search_filters": "Ouvrir les filtres de recherche",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Soutenir",
|
||||
"support_and_feedback": "Support & Retours",
|
||||
"support_third_party_description": "Votre installation d'Immich est packagée via une application tierce. Si vous rencontrez des anomalies, elles peuvent venir de ce packaging tiers, merci de créer les anomalies avec ces tiers en premier lieu en utilisant les liens ci-dessous.",
|
||||
"supporter": "Contributeur",
|
||||
"swap_merge_direction": "Inverser la direction de fusion",
|
||||
"sync": "Synchroniser",
|
||||
"sync_albums": "Synchroniser dans des albums",
|
||||
|
||||
10
i18n/ga.json
10
i18n/ga.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Cuardaigh poist…",
|
||||
"send_welcome_email": "Seol ríomhphost fáilte",
|
||||
"server_external_domain_settings": "Fearann seachtrach",
|
||||
"server_external_domain_settings_description": "Fearann le haghaidh naisc chomhroinnte poiblí, lena n-áirítear http(s)://",
|
||||
"server_external_domain_settings_description": "Fearann a úsáidtear le haghaidh naisc sheachtracha",
|
||||
"server_public_users": "Úsáideoirí Poiblí",
|
||||
"server_public_users_description": "Liostaítear gach úsáideoir (ainm agus ríomhphost) nuair a chuirtear úsáideoir le halbaim chomhroinnte. Nuair a bhíonn sé díchumasaithe, ní bheidh an liosta úsáideoirí ar fáil ach d’úsáideoirí riarthóra.",
|
||||
"server_settings": "Socruithe Freastalaí",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Dath",
|
||||
"color_theme": "Téama datha",
|
||||
"command": "Ordú",
|
||||
"command_palette_prompt": "Aimsigh leathanaigh, gníomhartha nó orduithe go tapa",
|
||||
"command_palette_to_close": "a dhúnadh",
|
||||
"command_palette_to_navigate": "dul isteach",
|
||||
"command_palette_to_select": "a roghnú",
|
||||
"command_palette_to_show_all": "chun gach rud a thaispeáint",
|
||||
"comment_deleted": "Trácht scriosta",
|
||||
"comment_options": "Roghanna tráchta",
|
||||
"comments_and_likes": "Tráchtanna & Is maith liom",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "DAOINE",
|
||||
"exif_bottom_sheet_person_add_person": "Cuir ainm leis",
|
||||
"exit_slideshow": "Scoir an Taispeántais Sleamhnán",
|
||||
"expand": "Leathnaigh",
|
||||
"expand_all": "Leathnaigh gach rud",
|
||||
"experimental_settings_new_asset_list_subtitle": "Obair ar siúl",
|
||||
"experimental_settings_new_asset_list_title": "Cumasaigh eangach grianghraf turgnamhach",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Ar líne",
|
||||
"only_favorites": "Is fearr leat amháin",
|
||||
"open": "Oscail",
|
||||
"open_calendar": "Oscail an féilire",
|
||||
"open_in_map_view": "Oscail i radharc léarscáile",
|
||||
"open_in_openstreetmap": "Oscail in OpenStreetMap",
|
||||
"open_the_search_filters": "Oscail na scagairí cuardaigh",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Tacaíocht",
|
||||
"support_and_feedback": "Tacaíocht & Aiseolas",
|
||||
"support_third_party_description": "Rinne tríú páirtí pacáiste de do shuiteáil Immich. 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",
|
||||
"sync": "Sioncrónaigh",
|
||||
"sync_albums": "Sioncrónaigh albaim",
|
||||
|
||||
10
i18n/it.json
10
i18n/it.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Cerca Attività…",
|
||||
"send_welcome_email": "Invia email di benvenuto",
|
||||
"server_external_domain_settings": "Dominio esterno",
|
||||
"server_external_domain_settings_description": "Dominio per link condivisi pubblicamente, incluso http(s)://",
|
||||
"server_external_domain_settings_description": "Dominio utilizzato per i link esterni",
|
||||
"server_public_users": "Utenti Pubblici",
|
||||
"server_public_users_description": "Tutti gli utenti (nome ed e-mail) sono elencati quando si aggiunge un utente agli album condivisi. Quando disabilitato, l'elenco degli utenti sarà disponibile solo per gli utenti amministratori.",
|
||||
"server_settings": "Impostazioni Server",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Colore",
|
||||
"color_theme": "Colore Tema",
|
||||
"command": "Comando",
|
||||
"command_palette_prompt": "Trova rapidamente pagine, azioni o comandi",
|
||||
"command_palette_to_close": "per chiudere",
|
||||
"command_palette_to_navigate": "per entrare",
|
||||
"command_palette_to_select": "per selezionare",
|
||||
"command_palette_to_show_all": "per mostrare tutto",
|
||||
"comment_deleted": "Commento eliminato",
|
||||
"comment_options": "Opzioni per i commenti",
|
||||
"comments_and_likes": "Commenti & mi piace",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "PERSONE",
|
||||
"exif_bottom_sheet_person_add_person": "Aggiungi nome",
|
||||
"exit_slideshow": "Esci dalla presentazione",
|
||||
"expand": "Espandi",
|
||||
"expand_all": "Espandi tutto",
|
||||
"experimental_settings_new_asset_list_subtitle": "Lavori in corso",
|
||||
"experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Online",
|
||||
"only_favorites": "Solo preferiti",
|
||||
"open": "Apri",
|
||||
"open_calendar": "Apri il calendario",
|
||||
"open_in_map_view": "Apri nella visualizzazione mappa",
|
||||
"open_in_openstreetmap": "Apri su OpenStreetMap",
|
||||
"open_the_search_filters": "Apri filtri di ricerca",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Supporto",
|
||||
"support_and_feedback": "Supporto & Feedback",
|
||||
"support_third_party_description": "La tua installazione di Immich è stata costruita da terze parti. I problemi che riscontri potrebbero essere causati da altri pacchetti, quindi ti preghiamo di sollevare il problema in prima istanza utilizzando i link sottostanti.",
|
||||
"supporter": "Sostenitore",
|
||||
"swap_merge_direction": "Scambia direzione di unione",
|
||||
"sync": "Sincronizza",
|
||||
"sync_albums": "Sincronizza album",
|
||||
|
||||
14
i18n/nl.json
14
i18n/nl.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Taak zoeken…",
|
||||
"send_welcome_email": "Stuur een welkomstmail",
|
||||
"server_external_domain_settings": "Extern domein",
|
||||
"server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://",
|
||||
"server_external_domain_settings_description": "Domein voor externe links",
|
||||
"server_public_users": "Openbare gebruikerslijst",
|
||||
"server_public_users_description": "Alle gebruikers (met naam en e-mailadres) worden weergegeven wanneer een gebruiker wordt toegevoegd aan gedeelde albums. Wanneer uitgeschakeld, is de gebruikerslijst alleen beschikbaar voor beheerders.",
|
||||
"server_settings": "Serverinstellingen",
|
||||
@@ -793,7 +793,12 @@
|
||||
"collapse_all": "Alles inklappen",
|
||||
"color": "Kleur",
|
||||
"color_theme": "Kleurenthema",
|
||||
"command": "Opdracht",
|
||||
"command": "Commando",
|
||||
"command_palette_prompt": "Vind snel pagina's, acties of commando's",
|
||||
"command_palette_to_close": "om te sluiten",
|
||||
"command_palette_to_navigate": "om te navigeren",
|
||||
"command_palette_to_select": "om te selecteren",
|
||||
"command_palette_to_show_all": "om alles te tonen",
|
||||
"comment_deleted": "Opmerking verwijderd",
|
||||
"comment_options": "Opties voor opmerkingen",
|
||||
"comments_and_likes": "Opmerkingen & likes",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "MENSEN",
|
||||
"exif_bottom_sheet_person_add_person": "Naam toevoegen",
|
||||
"exit_slideshow": "Diavoorstelling sluiten",
|
||||
"expand": "Uitklappen",
|
||||
"expand_all": "Alles uitvouwen",
|
||||
"experimental_settings_new_asset_list_subtitle": "Werk in uitvoering",
|
||||
"experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Online",
|
||||
"only_favorites": "Alleen favorieten",
|
||||
"open": "Openen",
|
||||
"open_calendar": "Open kalender",
|
||||
"open_in_map_view": "Openen in kaartweergave",
|
||||
"open_in_openstreetmap": "Openen in OpenStreetMap",
|
||||
"open_the_search_filters": "Open de zoekfilters",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Ondersteuning",
|
||||
"support_and_feedback": "Ondersteuning & feedback",
|
||||
"support_third_party_description": "Je Immich installatie is door een derde partij samengesteld. Problemen die je ervaart, kunnen door dat pakket veroorzaakt zijn. Meld problemen in eerste instantie bij hen via de onderstaande links.",
|
||||
"supporter": "Supporter",
|
||||
"swap_merge_direction": "Wissel richting voor samenvoegen om",
|
||||
"sync": "Synchroniseren",
|
||||
"sync_albums": "Albums synchroniseren",
|
||||
@@ -2294,7 +2302,7 @@
|
||||
"unstack_action_prompt": "{count} item(s) ontstapeld",
|
||||
"unstacked_assets_count": "{count, plural, one {# item} other {# items}} ontstapeld",
|
||||
"unsupported_field_type": "Veldtype niet ondersteund",
|
||||
"untagged": "Ongemarkeerd",
|
||||
"untagged": "Zonder tags",
|
||||
"untitled_workflow": "Naamloze werkstroom",
|
||||
"up_next": "Volgende",
|
||||
"update_location_action_prompt": "Werk de locatie bij van {count} geselecteerde items met:",
|
||||
|
||||
@@ -2064,7 +2064,7 @@
|
||||
"shared_by_you": "Udostępnione przez ciebie",
|
||||
"shared_from_partner": "Zdjęcia od {partner}",
|
||||
"shared_intent_upload_button_progress_text": "{current} / {total} Przesłano",
|
||||
"shared_link_app_bar_title": "Udostępnione linki",
|
||||
"shared_link_app_bar_title": "Udostępnione",
|
||||
"shared_link_clipboard_copied_massage": "Skopiowane do schowka",
|
||||
"shared_link_clipboard_text": "Link: {link}\nHasło: {password}",
|
||||
"shared_link_create_error": "Błąd podczas tworzenia linka do udostępnienia",
|
||||
|
||||
12
i18n/ru.json
12
i18n/ru.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Поиск задач…",
|
||||
"send_welcome_email": "Отправить приветственное письмо",
|
||||
"server_external_domain_settings": "Внешний домен",
|
||||
"server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://",
|
||||
"server_external_domain_settings_description": "Домен для публичных ссылок",
|
||||
"server_public_users": "Публичные пользователи",
|
||||
"server_public_users_description": "Выводить список пользователей (имена и email) в общих альбомах. Когда отключено, список доступен только администраторам, пользователи смогут делиться только ссылкой.",
|
||||
"server_settings": "Настройки сервера",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Цвет",
|
||||
"color_theme": "Цветовая тема",
|
||||
"command": "Команда",
|
||||
"command_palette_prompt": "Быстрый поиск страниц, действий или команд",
|
||||
"command_palette_to_close": "закрыть",
|
||||
"command_palette_to_navigate": "навигация",
|
||||
"command_palette_to_select": "выбрать",
|
||||
"command_palette_to_show_all": "показать все",
|
||||
"comment_deleted": "Комментарий удалён",
|
||||
"comment_options": "Действия с комментарием",
|
||||
"comments_and_likes": "Комментарии и отметки \"нравится\"",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "ЛЮДИ",
|
||||
"exif_bottom_sheet_person_add_person": "Добавить имя",
|
||||
"exit_slideshow": "Выйти из слайд-шоу",
|
||||
"expand": "Развернуть",
|
||||
"expand_all": "Развернуть всё",
|
||||
"experimental_settings_new_asset_list_subtitle": "В разработке",
|
||||
"experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Доступен",
|
||||
"only_favorites": "Только избранное",
|
||||
"open": "Открыть",
|
||||
"open_calendar": "Открыть календарь",
|
||||
"open_in_map_view": "Открыть в режиме просмотра карты",
|
||||
"open_in_openstreetmap": "Открыть в OpenStreetMap",
|
||||
"open_the_search_filters": "Открыть фильтры поиска",
|
||||
@@ -2128,7 +2135,7 @@
|
||||
"show_search_options": "Показать параметры поиска",
|
||||
"show_shared_links": "Показать публичные ссылки",
|
||||
"show_slideshow_transition": "Плавный переход",
|
||||
"show_supporter_badge": "Значок поддержки",
|
||||
"show_supporter_badge": "Значок спонсорства",
|
||||
"show_supporter_badge_description": "Показать значок поддержки",
|
||||
"show_text_recognition": "Показать распознанный текст",
|
||||
"show_text_search_menu": "Показать меню текстового поиска",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Поддержка",
|
||||
"support_and_feedback": "Поддержка и обратная связь",
|
||||
"support_third_party_description": "Ваша установка immich была упакована сторонним разработчиком. Проблемы, с которыми вы столкнулись, могут быть вызваны этим пакетом, поэтому, пожалуйста, в первую очередь обращайтесь к ним, используя ссылки ниже.",
|
||||
"supporter": "Спонсор Immich",
|
||||
"swap_merge_direction": "Изменить направление слияния",
|
||||
"sync": "Синхр.",
|
||||
"sync_albums": "Синхронизировать альбомы",
|
||||
|
||||
10
i18n/sk.json
10
i18n/sk.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Vyhľadať úlohy…",
|
||||
"send_welcome_email": "Odoslať uvítací e-mail",
|
||||
"server_external_domain_settings": "Externá doména",
|
||||
"server_external_domain_settings_description": "Verejná doména pre zdieľané odkazy, vrátane http(s)://",
|
||||
"server_external_domain_settings_description": "Doména používaná pre externé odkazy",
|
||||
"server_public_users": "Verejní používatelia",
|
||||
"server_public_users_description": "Všetci používatelia (meno a email) sú uvedení pri pridávaní používateľa do zdieľaných albumov. Ak je táto funkcia vypnutá, zoznam používateľov bude dostupný iba správcom.",
|
||||
"server_settings": "Server",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Farba",
|
||||
"color_theme": "Farba témy",
|
||||
"command": "Príkaz",
|
||||
"command_palette_prompt": "Rýchlo vyhľadajte stránky, akcie alebo príkazy ako",
|
||||
"command_palette_to_close": "zatvoriť",
|
||||
"command_palette_to_navigate": "vložiť",
|
||||
"command_palette_to_select": "vybrať",
|
||||
"command_palette_to_show_all": "zobraziť všetko",
|
||||
"comment_deleted": "Komentár bol odstránený",
|
||||
"comment_options": "Možnosti komentára",
|
||||
"comments_and_likes": "Komentáre a páči sa mi to",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "ĽUDIA",
|
||||
"exif_bottom_sheet_person_add_person": "Pridať meno",
|
||||
"exit_slideshow": "Opustiť prezentáciu",
|
||||
"expand": "Rozbaliť",
|
||||
"expand_all": "Rozbaliť všetko",
|
||||
"experimental_settings_new_asset_list_subtitle": "Prebiehajúca práca",
|
||||
"experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Online",
|
||||
"only_favorites": "Len obľúbené",
|
||||
"open": "Otvoriť",
|
||||
"open_calendar": "Otvoriť kalendár",
|
||||
"open_in_map_view": "Otvoriť v mape",
|
||||
"open_in_openstreetmap": "Otvoriť v OpenStreetMap",
|
||||
"open_the_search_filters": "Otvoriť vyhľadávacie filtre",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Podpora",
|
||||
"support_and_feedback": "Podpora a spätná väzba",
|
||||
"support_third_party_description": "Vaša inštalácia Immich bola pripravená treťou stranou. Problémy, ktoré sa vyskytli, môžu byť spôsobené týmto balíčkom, preto sa na nich obráťte v prvom rade cez nasledujúce odkazy.",
|
||||
"supporter": "Podporovateľ",
|
||||
"swap_merge_direction": "Vymeniť smer zlúčenia",
|
||||
"sync": "Synchronizovať",
|
||||
"sync_albums": "Synchronizovať albumy",
|
||||
|
||||
10
i18n/sl.json
10
i18n/sl.json
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "Išči opravila…",
|
||||
"send_welcome_email": "Pošlji pozdravno e-pošto",
|
||||
"server_external_domain_settings": "Zunanja domena",
|
||||
"server_external_domain_settings_description": "Domena za javne skupne povezave, vključno s http(s)://",
|
||||
"server_external_domain_settings_description": "Domena, uporabljena za zunanje povezave",
|
||||
"server_public_users": "Javni uporabniki",
|
||||
"server_public_users_description": "Vsi uporabniki (ime in e-pošta) so navedeni pri dodajanju uporabnika v albume v skupni rabi. Ko je onemogočen, bo seznam uporabnikov na voljo samo skrbniškim uporabnikom.",
|
||||
"server_settings": "Nastavitve strežnika",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "Barva",
|
||||
"color_theme": "Barva teme",
|
||||
"command": "Ukaz",
|
||||
"command_palette_prompt": "Hitro iskanje strani, dejanj ali ukazov",
|
||||
"command_palette_to_close": "zapreti",
|
||||
"command_palette_to_navigate": "vstopiti",
|
||||
"command_palette_to_select": "izbrati",
|
||||
"command_palette_to_show_all": "prikazati vse",
|
||||
"comment_deleted": "Komentar izbrisan",
|
||||
"comment_options": "Možnosti komentiranja",
|
||||
"comments_and_likes": "Komentarji in všečki",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "OSEBE",
|
||||
"exif_bottom_sheet_person_add_person": "Dodaj ime",
|
||||
"exit_slideshow": "Zapustite diaprojekcijo",
|
||||
"expand": "Razširi",
|
||||
"expand_all": "Razširi vse",
|
||||
"experimental_settings_new_asset_list_subtitle": "Delo v teku",
|
||||
"experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "Povezano",
|
||||
"only_favorites": "Samo priljubljene",
|
||||
"open": "Odpri",
|
||||
"open_calendar": "Odpri koledar",
|
||||
"open_in_map_view": "Odpri v pogledu zemljevida",
|
||||
"open_in_openstreetmap": "Odpri v OpenStreetMap",
|
||||
"open_the_search_filters": "Odpri iskalne filtre",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "Podpora",
|
||||
"support_and_feedback": "Podpora in povratne informacije",
|
||||
"support_third_party_description": "Vašo namestitev Immich je pakirala tretja oseba. Težave, ki jih imate, lahko povzroči ta paket, zato prosimo, da težave najprej izpostavite njim, tako da uporabite spodnje povezave.",
|
||||
"supporter": "Podpornik",
|
||||
"swap_merge_direction": "Zamenjaj smer združevanja",
|
||||
"sync": "Sinhronizacija",
|
||||
"sync_albums": "Sinhronizacija albumov",
|
||||
|
||||
24
i18n/th.json
24
i18n/th.json
@@ -6,8 +6,8 @@
|
||||
"action": "ดำเนินการ",
|
||||
"action_common_update": "อัปเดต",
|
||||
"actions": "การดำเนินการ",
|
||||
"active": "ใช้งานอยู่",
|
||||
"active_count": "ใช้งานอยู่: {count}",
|
||||
"active": "กำลังทำงาน",
|
||||
"active_count": "กำลังทำงาน: {count}",
|
||||
"activity": "กิจกรรม",
|
||||
"activity_changed": "กิจกรรม{enabled, select, true {เปิด} other {ปิด}}อยู่",
|
||||
"add": "เพิ่ม",
|
||||
@@ -27,7 +27,7 @@
|
||||
"add_path": "เพิ่มพาทที่ตั้ง",
|
||||
"add_photos": "เพิ่มรูปภาพ",
|
||||
"add_tag": "เพิ่มแท็ก",
|
||||
"add_to": "เพิ่มไปยัง …",
|
||||
"add_to": "เพิ่มไปยัง…",
|
||||
"add_to_album": "เพิ่มไปยังอัลบั้ม",
|
||||
"add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album} แล้ว",
|
||||
"add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว",
|
||||
@@ -200,7 +200,7 @@
|
||||
"metadata_settings": "การตั้งค่า Metadata",
|
||||
"metadata_settings_description": "จัดการการตั้งค่า Metadata",
|
||||
"migration_job": "การโยกย้าย",
|
||||
"migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
|
||||
"migration_job_description": "ย้ายภาพตัวอย่างสำหรับสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด",
|
||||
"nightly_tasks_cluster_new_faces_setting": "คลัสเตอร์ใบหน้าใหม่",
|
||||
"nightly_tasks_generate_memories_setting": "สร้างความทรงจำ",
|
||||
"nightly_tasks_generate_memories_setting_description": "สร้างความทรงจำใหม่จากสื่อ",
|
||||
@@ -526,20 +526,20 @@
|
||||
"asset_viewer_settings_subtitle": "ตั้งค่าการแสดงแกลเลอรี",
|
||||
"asset_viewer_settings_title": "ตัวดูทรัพยากร",
|
||||
"assets": "สื่อ",
|
||||
"assets_added_count": "เพิ่ม {count, plural, one{# สื่อ} other {# สื่อ}} แล้ว",
|
||||
"assets_added_count": "เพิ่มสื่อ {count, plural, one{# รายการ} other {# รายการ}}แล้ว",
|
||||
"assets_added_to_album_count": "เพิ่ม {count, plural, one {# asset} other {# assets}} ไปยังอัลบั้ม",
|
||||
"assets_added_to_albums_count": "เพิ่มสื่อ {assetTotal, plural, one {# รายการ} other {# รายการ}} ไปยังอัลบั้ม {albumTotal, plural, one {# รายการ} other {# รายการ}}แล้ว",
|
||||
"assets_cannot_be_added_to_album_count": "ไม่สามารถเพิ่ม {count, plural, one {สื่อ} other {สื่อ}} ไปยังอัลบั้ม",
|
||||
"assets_cannot_be_added_to_albums": "ไม่สามารถเพิ่ม{count, plural, one {สื่อ} other {สื่อ}}ไปยังอัลบั้มใด ๆ ได้",
|
||||
"assets_count": "{count, plural, one { สื่อ} other { สื่อ}}",
|
||||
"assets_count": "สื่อ {count, plural, one {# รายการ} other {# รายการ}}",
|
||||
"assets_deleted_permanently": "{count} สื่อถูกลบอย่างถาวร",
|
||||
"assets_deleted_permanently_from_server": "ลบ {count} สื่อออกจาก Immich อย่างถาวร",
|
||||
"assets_deleted_permanently_from_server": "ลบสื่อ {count} รายการออกจากเซิร์ฟเวอร์ Immich อย่างถาวรแล้ว",
|
||||
"assets_downloaded_failed": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} ไม่สำเร็จ - {error}",
|
||||
"assets_downloaded_successfully": "ดาวน์โหลด {count, plural, one {ไฟล์} other {ไฟล์}} สำเร็จ",
|
||||
"assets_moved_to_trash_count": "ย้าย {count, plural, one {# asset} other {# assets}} ไปยังถังขยะแล้ว",
|
||||
"assets_permanently_deleted_count": "ลบ {count, plural, one {# asset} other {# assets}} ทิ้งถาวร",
|
||||
"assets_removed_count": "{count, plural, one {# asset} other {# assets}} ถูกลบแล้ว",
|
||||
"assets_removed_permanently_from_device": "นำ {count} สื่อออกจากอุปกรณ์อย่างถาวร",
|
||||
"assets_removed_permanently_from_device": "ลบสื่อ {count} รายการออกจากอุปกรณ์ของคุณอย่างถาวรแล้ว",
|
||||
"assets_restore_confirmation": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนสื่อที่ทิ้งทั้งหมด? คุณไม่สามารถย้อนกลับการดำเนินการนี้ได้! โปรดทราบว่าสื่อออฟไลน์ใดๆ ไม่สามารถกู้คืนได้ด้วยวิธีนี้",
|
||||
"assets_restored_count": "{count, plural, one {# asset} other {# assets}} คืนค่า",
|
||||
"assets_restored_successfully": "กู้คืน {count} สื่อสำเร็จ",
|
||||
@@ -699,6 +699,8 @@
|
||||
"cleanup_found_assets": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว",
|
||||
"cleanup_found_assets_with_size": "พบสื่อ {count} รายการที่สำรองข้อมูลแล้ว ({size})",
|
||||
"cleanup_icloud_shared_albums_excluded": "อัลบั้มที่แชร์บน iCloud ไม่นับรวมในการค้นหา",
|
||||
"cleanup_no_assets_found": "ไม่พบสื่อที่ตรงตามเงื่อนไขด้านบน \"เพิ่มพื้นที่ว่าง\" สามารถลบได้เฉพาะสื่อที่สำรองข้อมูลบนเซิร์ฟเวอร์เรียบร้อยแล้วเท่านั้น",
|
||||
"cleanup_preview_title": "สื่อที่จะลบ ({count})",
|
||||
"clear": "ล้าง",
|
||||
"clear_all": "ล้างทั้งหมด",
|
||||
"clear_all_recent_searches": "ล้างประวัติการค้นหา",
|
||||
@@ -1062,7 +1064,7 @@
|
||||
"export_as_json": "ส่งออกเป็นไฟล์ JSON",
|
||||
"extension": "ส่วนต่อขยาย",
|
||||
"external": "ภายนอก",
|
||||
"external_libraries": "ภายนอกคลังภาพ",
|
||||
"external_libraries": "คลังภาพภายนอก",
|
||||
"external_network": "การเชื่อมต่อภายนอก",
|
||||
"external_network_sheet_info": "เมื่อไม่ได้เชื่อมต่อ Wi-Fi ที่เลือกไว้ แอพจะเชื่อมต่อเซิร์ฟเวอร์ผ่าน URL ด้านล่างตามลำดับ",
|
||||
"face_unassigned": "ไม่กำหนดมอบหมาย",
|
||||
@@ -1658,8 +1660,8 @@
|
||||
"server_endpoint": "ปลายทางเซิร์ฟเวอร์",
|
||||
"server_info_box_app_version": "เวอร์ชันแอพ",
|
||||
"server_info_box_server_url": "URL เซิร์ฟเวอร์",
|
||||
"server_offline": "Server ออฟไลน์",
|
||||
"server_online": "Server ออนไลน์",
|
||||
"server_offline": "เซิร์ฟเวอร์ออฟไลน์",
|
||||
"server_online": "เซิร์ฟเวอร์ออนไลน์",
|
||||
"server_privacy": "ความเป็นส่วนตัวเซิร์ฟเวอร์",
|
||||
"server_stats": "สถิติเซิร์ฟเวอร์",
|
||||
"server_version": "เวอร์ชันของเซิร์ฟเวอร์",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"add_to_bottom_bar": "加至",
|
||||
"add_to_shared_album": "加至共享相簿",
|
||||
"add_url": "加網址",
|
||||
"add_workflow_step": "增加工作步驟",
|
||||
"added_to_favorites": "已加至最愛",
|
||||
"added_to_favorites_count": "已加{count, number} 個項目至最愛",
|
||||
"admin": {
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
"search_jobs": "搜尋任務…",
|
||||
"send_welcome_email": "傳送歡迎電子郵件",
|
||||
"server_external_domain_settings": "外部網域",
|
||||
"server_external_domain_settings_description": "公開分享連結的網域,包含 http(s)://",
|
||||
"server_external_domain_settings_description": "公開分享連結的網域",
|
||||
"server_public_users": "公開使用者",
|
||||
"server_public_users_description": "將使用者新增至共享相簿時,會列出所有使用者(姓名與電子郵件)。若停用,使用者清單將僅供管理員查看。",
|
||||
"server_settings": "伺服器設定",
|
||||
@@ -794,6 +794,11 @@
|
||||
"color": "顏色",
|
||||
"color_theme": "色彩主題",
|
||||
"command": "命令",
|
||||
"command_palette_prompt": "快速尋找頁面,動作或者指令",
|
||||
"command_palette_to_close": "關閉",
|
||||
"command_palette_to_navigate": "輸入",
|
||||
"command_palette_to_select": "選擇",
|
||||
"command_palette_to_show_all": "顯示全部",
|
||||
"comment_deleted": "留言已刪除",
|
||||
"comment_options": "留言選項",
|
||||
"comments_and_likes": "留言與喜歡",
|
||||
@@ -1168,6 +1173,7 @@
|
||||
"exif_bottom_sheet_people": "人物",
|
||||
"exif_bottom_sheet_person_add_person": "新增姓名",
|
||||
"exit_slideshow": "結束幻燈片",
|
||||
"expand": "展開",
|
||||
"expand_all": "展開全部",
|
||||
"experimental_settings_new_asset_list_subtitle": "正在處理",
|
||||
"experimental_settings_new_asset_list_title": "啟用實驗性相片格狀版面",
|
||||
@@ -1642,6 +1648,7 @@
|
||||
"online": "線上",
|
||||
"only_favorites": "僅顯示己收藏",
|
||||
"open": "開啟",
|
||||
"open_calendar": "打開日曆",
|
||||
"open_in_map_view": "開啟地圖檢視",
|
||||
"open_in_openstreetmap": "用 OpenStreetMap 開啟",
|
||||
"open_the_search_filters": "開啟搜尋篩選器",
|
||||
@@ -1916,7 +1923,7 @@
|
||||
"search_by_description_example": "在沙壩的健行之日",
|
||||
"search_by_filename": "依檔名或副檔名搜尋",
|
||||
"search_by_filename_example": "如 IMG_1234.JPG 或 PNG",
|
||||
"search_by_ocr": "通過OCR蒐索",
|
||||
"search_by_ocr": "透過OCR搜尋",
|
||||
"search_by_ocr_example": "拿鐵",
|
||||
"search_camera_lens_model": "蒐索鏡頭型號...",
|
||||
"search_camera_make": "搜尋相機製造商…",
|
||||
@@ -1935,7 +1942,7 @@
|
||||
"search_filter_location_title": "選擇位置",
|
||||
"search_filter_media_type": "媒體類型",
|
||||
"search_filter_media_type_title": "選擇媒體類型",
|
||||
"search_filter_ocr": "通過OCR蒐索",
|
||||
"search_filter_ocr": "透過OCR搜尋",
|
||||
"search_filter_people_title": "選擇人物",
|
||||
"search_filter_star_rating": "評分",
|
||||
"search_for": "搜尋",
|
||||
@@ -2183,6 +2190,7 @@
|
||||
"support": "支援",
|
||||
"support_and_feedback": "支援與回饋",
|
||||
"support_third_party_description": "您安裝的 Immich 是由第三方打包的。您遇到的問題可能是該套件造成的,所以請先使用下面的連結向他們提出問題。",
|
||||
"supporter": "支持者",
|
||||
"swap_merge_direction": "交換合併方向",
|
||||
"sync": "同步",
|
||||
"sync_albums": "同步相簿",
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
android:pathPrefix="/albums/" />
|
||||
<data
|
||||
android:host="my.immich.app"
|
||||
android:pathPrefix="/memories/" />
|
||||
android:pathPrefix="/people/" />
|
||||
<data
|
||||
android:host="my.immich.app"
|
||||
android:path="/memory" />
|
||||
|
||||
@@ -10,6 +10,10 @@ class DriftPeopleService {
|
||||
|
||||
const DriftPeopleService(this._repository, this._personApiRepository);
|
||||
|
||||
Future<DriftPerson?> get(String personId) {
|
||||
return _repository.get(personId);
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) {
|
||||
return _repository.getAssetPeople(assetId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -7,6 +8,13 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftPeopleRepository(this._db) : super(_db);
|
||||
|
||||
Future<DriftPerson?> get(String personId) async {
|
||||
final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.toDto();
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||
final query = _db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
@@ -19,19 +27,28 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAllPeople() async {
|
||||
final people = _db.personEntity;
|
||||
final faces = _db.assetFaceEntity;
|
||||
final assets = _db.remoteAssetEntity;
|
||||
|
||||
final query =
|
||||
_db.select(_db.personEntity).join([
|
||||
leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)),
|
||||
_db.select(people).join([
|
||||
innerJoin(faces, faces.personId.equalsExp(people.id)),
|
||||
innerJoin(assets, assets.id.equalsExp(faces.assetId)),
|
||||
])
|
||||
..where(_db.personEntity.isHidden.equals(false))
|
||||
..groupBy([_db.personEntity.id], having: _db.assetFaceEntity.id.count().isBiggerOrEqualValue(3))
|
||||
..where(
|
||||
people.isHidden.equals(false) &
|
||||
assets.deletedAt.isNull() &
|
||||
assets.visibility.equalsValue(AssetVisibility.timeline),
|
||||
)
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
|
||||
..orderBy([
|
||||
OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
|
||||
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
|
||||
]);
|
||||
|
||||
return query.map((row) {
|
||||
final person = row.readTable(_db.personEntity);
|
||||
final person = row.readTable(people);
|
||||
return person.toDto();
|
||||
}).get();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ServerInfo {
|
||||
|
||||
const ServerInfo({
|
||||
required this.serverVersion,
|
||||
required this.latestVersion,
|
||||
this.latestVersion,
|
||||
required this.serverFeatures,
|
||||
required this.serverConfig,
|
||||
required this.serverDiskInfo,
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
class TimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double height;
|
||||
final List<double> widths;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
const TimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.height,
|
||||
required this.widths,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
factory TimelineRow.fixed({
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
required List<Widget> children,
|
||||
}) => TimelineRow(
|
||||
height: dimension,
|
||||
widths: List.filled(children.length, dimension),
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
children: children,
|
||||
);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
|
||||
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.height = height;
|
||||
renderObject.widths = widths;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
double get height => _height;
|
||||
double _height;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
set height(double value) {
|
||||
if (_height == value) return;
|
||||
_height = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
List<double> get widths => _widths;
|
||||
List<double> _widths;
|
||||
|
||||
set widths(List<double> value) {
|
||||
if (listEquals(_widths, value)) return;
|
||||
_widths = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
|
||||
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
double computeMinIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
double computeMaxIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
size = Size(constraints.maxWidth, height);
|
||||
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
int childIndex = 0;
|
||||
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
while (child != null && childIndex < widths.length) {
|
||||
final width = widths[childIndex];
|
||||
final childConstraints = BoxConstraints.tight(Size(width, height));
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
childParentData.offset = Offset(currentX, 0);
|
||||
child = childParentData.nextSibling;
|
||||
childIndex++;
|
||||
|
||||
if (child != null && childIndex < widths.length) {
|
||||
final nextWidth = widths[childIndex];
|
||||
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||
@@ -78,6 +80,7 @@ class FixedSegment extends Segment {
|
||||
assetCount: numberOfAssets,
|
||||
tileHeight: tileHeight,
|
||||
spacing: spacing,
|
||||
columnCount: columnCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const _FixedSegmentRow({
|
||||
required this.assetIndex,
|
||||
required this.assetCount,
|
||||
required this.tileHeight,
|
||||
required this.spacing,
|
||||
required this.columnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
timelineService,
|
||||
isDynamicLayout,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
@@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
List<BaseAsset> assets,
|
||||
TimelineService timelineService,
|
||||
bool isDynamicLayout,
|
||||
) {
|
||||
final children = [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final widths = List.filled(assets.length, tileHeight);
|
||||
|
||||
if (isDynamicLayout) {
|
||||
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize to get width distribution
|
||||
final sum = arConfiguration.sum;
|
||||
|
||||
int index = 0;
|
||||
for (final ratio in arConfiguration) {
|
||||
// Distribute the available width proportionally based on aspect ratio configuration
|
||||
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return TimelineDragRegion(
|
||||
child: TimelineRow(
|
||||
height: tileHeight,
|
||||
widths: widths,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
child: TimelineRow.fixed(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
|
||||
@@ -15,7 +15,6 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
: super(
|
||||
const ServerInfo(
|
||||
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||
latestVersion: null,
|
||||
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
|
||||
serverConfig: ServerConfig(
|
||||
trashDays: 30,
|
||||
@@ -104,7 +103,9 @@ final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfo>
|
||||
|
||||
final versionWarningPresentProvider = Provider.family<bool, UserDto?>((ref, user) {
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
return serverInfo.versionStatus == VersionStatus.clientOutOfDate ||
|
||||
serverInfo.versionStatus == VersionStatus.error ||
|
||||
((user?.isAdmin ?? false) && serverInfo.versionStatus == VersionStatus.serverOutOfDate);
|
||||
return switch (serverInfo.versionStatus) {
|
||||
VersionStatus.clientOutOfDate || VersionStatus.error => true,
|
||||
VersionStatus.serverOutOfDate => serverInfo.latestVersion != null && (user?.isAdmin ?? false),
|
||||
VersionStatus.upToDate => false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart' as beta_asset_service;
|
||||
import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||
import 'package:immich_mobile/domain/services/people.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -33,6 +35,7 @@ final deepLinkServiceProvider = Provider(
|
||||
ref.watch(beta_asset_provider.assetServiceProvider),
|
||||
ref.watch(remoteAlbumServiceProvider),
|
||||
ref.watch(driftMemoryServiceProvider),
|
||||
ref.watch(driftPeopleServiceProvider),
|
||||
ref.watch(currentUserProvider),
|
||||
),
|
||||
);
|
||||
@@ -49,7 +52,8 @@ class DeepLinkService {
|
||||
final TimelineFactory _betaTimelineFactory;
|
||||
final beta_asset_service.AssetService _betaAssetService;
|
||||
final RemoteAlbumService _betaRemoteAlbumService;
|
||||
final DriftMemoryService _betaMemoryServiceProvider;
|
||||
final DriftMemoryService _betaMemoryService;
|
||||
final DriftPeopleService _betaPeopleService;
|
||||
|
||||
final UserDto? _currentUser;
|
||||
|
||||
@@ -62,7 +66,8 @@ class DeepLinkService {
|
||||
this._betaTimelineFactory,
|
||||
this._betaAssetService,
|
||||
this._betaRemoteAlbumService,
|
||||
this._betaMemoryServiceProvider,
|
||||
this._betaMemoryService,
|
||||
this._betaPeopleService,
|
||||
this._currentUser,
|
||||
);
|
||||
|
||||
@@ -84,6 +89,7 @@ class DeepLinkService {
|
||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''),
|
||||
"activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''),
|
||||
_ => null,
|
||||
};
|
||||
@@ -106,6 +112,7 @@ class DeepLinkService {
|
||||
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
final assetRegex = RegExp('/photos/($uuidRegex)');
|
||||
final albumRegex = RegExp('/albums/($uuidRegex)');
|
||||
final peopleRegex = RegExp('/people/($uuidRegex)');
|
||||
|
||||
PageRouteInfo<dynamic>? deepLinkRoute;
|
||||
if (assetRegex.hasMatch(path)) {
|
||||
@@ -114,6 +121,9 @@ class DeepLinkService {
|
||||
} else if (albumRegex.hasMatch(path)) {
|
||||
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
||||
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
||||
} else if (peopleRegex.hasMatch(path)) {
|
||||
final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? '';
|
||||
deepLinkRoute = await _buildPeopleDeepLink(peopleId);
|
||||
} else if (path == "/memory") {
|
||||
deepLinkRoute = await _buildMemoryDeepLink(null);
|
||||
}
|
||||
@@ -136,9 +146,9 @@ class DeepLinkService {
|
||||
return null;
|
||||
}
|
||||
|
||||
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
|
||||
memories = await _betaMemoryService.getMemoryLane(_currentUser.id);
|
||||
} else {
|
||||
final memory = await _betaMemoryServiceProvider.get(memoryId);
|
||||
final memory = await _betaMemoryService.get(memoryId);
|
||||
if (memory != null) {
|
||||
memories = [memory];
|
||||
}
|
||||
@@ -225,4 +235,18 @@ class DeepLinkService {
|
||||
|
||||
return DriftActivitiesRoute(album: album);
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildPeopleDeepLink(String personId) async {
|
||||
if (Store.isBetaTimelineEnabled == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final person = await _betaPeopleService.get(personId);
|
||||
|
||||
if (person == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DriftPersonRoute(person: person);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
final bool showVersionWarning = ref.watch(versionWarningPresentProvider(user));
|
||||
|
||||
final appInfo = useState({});
|
||||
const titleFontSize = 12.0;
|
||||
const contentFontSize = 11.0;
|
||||
|
||||
getPackageInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
@@ -37,176 +35,38 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const divider = Divider(thickness: 1);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (showVersionWarning) ...[
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 8.0), child: ServerUpdateNotification()),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_info_box_app_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showVersionWarning) ...[const ServerUpdateNotification(), divider],
|
||||
_ServerInfoItem(
|
||||
label: "server_info_box_app_version".tr(),
|
||||
text: "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.serverVersion.major > 0
|
||||
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_info_box_server_url".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Container(
|
||||
width: 200,
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Tooltip(
|
||||
verticalOffset: 0,
|
||||
decoration: BoxDecoration(
|
||||
color: context.primaryColor.withValues(alpha: 0.9),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
color: context.isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
message: getServerUrl() ?? '--',
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Text(
|
||||
getServerUrl() ?? '--',
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
divider,
|
||||
_ServerInfoItem(
|
||||
label: "server_version".tr(),
|
||||
text: serverInfoState.serverVersion.major > 0
|
||||
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
|
||||
: "--",
|
||||
),
|
||||
divider,
|
||||
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
|
||||
if (serverInfoState.latestVersion != null) ...[
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
|
||||
),
|
||||
Text(
|
||||
"latest_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion!.major > 0
|
||||
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
divider,
|
||||
_ServerInfoItem(
|
||||
label: "latest_version".tr(),
|
||||
text: serverInfoState.latestVersion!.major > 0
|
||||
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
|
||||
: "--",
|
||||
tooltip: true,
|
||||
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
|
||||
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -214,3 +74,64 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerInfoItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String text;
|
||||
final bool tooltip;
|
||||
final Icon? icon;
|
||||
|
||||
static const titleFontSize = 12.0;
|
||||
static const contentFontSize = 11.0;
|
||||
|
||||
const _ServerInfoItem({required this.label, required this.text, this.tooltip = false, this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (icon != null) ...[icon as Widget, const SizedBox(width: 8)],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _maybeTooltip(
|
||||
context,
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _maybeTooltip(BuildContext context, Widget child) => tooltip
|
||||
? Tooltip(
|
||||
verticalOffset: 0,
|
||||
decoration: BoxDecoration(
|
||||
color: context.primaryColor.withValues(alpha: 0.9),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
textStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.bold),
|
||||
message: text,
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
title: "asset_list_layout_sub_title".t(context: context),
|
||||
icon: Icons.view_module_outlined,
|
||||
),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
if (!Store.isBetaTimelineEnabled)
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
valueNotifier: tilesPerRow,
|
||||
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
||||
|
||||
@@ -1217,10 +1217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1910,10 +1910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -14,11 +14,12 @@ import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
||||
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
|
||||
import { repositories } from 'src/repositories';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
@@ -46,7 +47,12 @@ const commonMiddleware = [
|
||||
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
||||
];
|
||||
|
||||
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
||||
const apiMiddleware = [
|
||||
AssetUploadInterceptor,
|
||||
UserProfileUploadInterceptor,
|
||||
...commonMiddleware,
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
];
|
||||
|
||||
const configRepository = new ConfigRepository();
|
||||
const { bull, cls, database, otel } = configRepository.getEnv();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
UploadedFiles as Files,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -35,18 +35,17 @@ import {
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
|
||||
import { ApiTag, ImmichHeader, Permission } from 'src/enum';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
|
||||
import { mapUploadedFile, UploadFiles, UploadRequest } from 'src/middleware/upload.interceptor';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
@Controller('assets')
|
||||
export class AssetMediaController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
@@ -55,7 +54,7 @@ export class AssetMediaController {
|
||||
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@UseInterceptors(AssetUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.Checksum,
|
||||
@@ -80,12 +79,21 @@ export class AssetMediaController {
|
||||
})
|
||||
async uploadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
||||
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaCreateDto,
|
||||
@Req() req: UploadRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file, sidecarFile } = getFiles(files);
|
||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
||||
const file = files[UploadFieldName.ASSET_DATA][0];
|
||||
const sidecarFile = files[UploadFieldName.SIDECAR_DATA]?.[0];
|
||||
|
||||
const responseDto = await this.service.uploadAsset(
|
||||
auth,
|
||||
dto,
|
||||
mapUploadedFile(req, file),
|
||||
sidecarFile ? mapUploadedFile(req, sidecarFile) : undefined,
|
||||
);
|
||||
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
@@ -113,7 +121,7 @@ export class AssetMediaController {
|
||||
}
|
||||
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@UseInterceptors(AssetUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -129,13 +137,19 @@ export class AssetMediaController {
|
||||
async replaceAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
|
||||
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaReplaceDto,
|
||||
@Req() req: UploadRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
const responseDto = await this.service.replaceAsset(
|
||||
auth,
|
||||
id,
|
||||
dto,
|
||||
mapUploadedFile(req, files[UploadFieldName.ASSET_DATA][0]),
|
||||
);
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
@Controller('assets')
|
||||
export class AssetController {
|
||||
constructor(private service: AssetService) {}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
@@ -92,10 +103,7 @@ export class DatabaseBackupController {
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
@@ -22,16 +22,17 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { UploadedFile } from 'src/middleware/upload.interceptor';
|
||||
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Users)
|
||||
@Controller(RouteKey.User)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private service: UserService,
|
||||
@@ -177,7 +178,7 @@ export class UserController {
|
||||
|
||||
@Post('profile-image')
|
||||
@Authenticated({ permission: Permission.UserProfileImageUpdate })
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@UseInterceptors(UserProfileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
|
||||
@Endpoint({
|
||||
@@ -185,11 +186,8 @@ export class UserController {
|
||||
description: 'Upload and set a new profile image for the current user.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
createProfileImage(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFile() fileInfo: Express.Multer.File,
|
||||
): Promise<CreateProfileImageResponseDto> {
|
||||
return this.service.createProfileImage(auth, fileInfo);
|
||||
createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||
return this.service.createProfileImage(auth, file);
|
||||
}
|
||||
|
||||
@Delete('profile-image')
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
|
||||
describe('mapAsset', () => {
|
||||
describe('peopleWithFaces', () => {
|
||||
it('should transform all faces when a person has multiple faces in the same image', () => {
|
||||
const person = PersonFactory.create();
|
||||
const face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
@@ -18,8 +18,6 @@ describe('mapAsset', () => {
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'assetFaceId-second',
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 400,
|
||||
boundingBoxX2: 400,
|
||||
@@ -28,16 +26,22 @@ describe('mapAsset', () => {
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [face1, face2],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
};
|
||||
const asset = AssetFactory.from()
|
||||
.face(face1, (builder) => builder.person(person))
|
||||
.face(face2, (builder) => builder.person(person))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.edit({
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
width: 1512,
|
||||
height: 1152,
|
||||
x: 216,
|
||||
y: 1512,
|
||||
},
|
||||
})
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
@@ -61,32 +65,22 @@ describe('mapAsset', () => {
|
||||
});
|
||||
|
||||
it('should transform unassigned faces with edits and dimensions', () => {
|
||||
const unassignedFace = {
|
||||
...faceStub.noPerson1,
|
||||
const unassignedFace = AssetFaceFactory.create({
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
});
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [unassignedFace],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 50, y: 50, width: 500, height: 400 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const asset = AssetFactory.from()
|
||||
.face(unassignedFace)
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.unassignedFaces).toBeDefined();
|
||||
expect(result.unassignedFaces).toHaveLength(1);
|
||||
@@ -101,10 +95,6 @@ describe('mapAsset', () => {
|
||||
|
||||
it('should handle multiple people each with multiple faces', () => {
|
||||
const person1Face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-1',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
@@ -114,10 +104,6 @@ describe('mapAsset', () => {
|
||||
};
|
||||
|
||||
const person1Face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-2',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
@@ -127,10 +113,6 @@ describe('mapAsset', () => {
|
||||
};
|
||||
|
||||
const person2Face1 = {
|
||||
...faceStub.mergeFace1,
|
||||
id: 'face-2-1',
|
||||
person: personStub.mergePerson,
|
||||
personId: personStub.mergePerson.id,
|
||||
boundingBoxX1: 500,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 600,
|
||||
@@ -139,23 +121,22 @@ describe('mapAsset', () => {
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [person1Face1, person1Face2, person2Face1],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [],
|
||||
};
|
||||
const person = PersonFactory.create({ id: 'person-1' });
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
const asset = AssetFactory.from()
|
||||
.face(person1Face1, (builder) => builder.person(person))
|
||||
.face(person1Face2, (builder) => builder.person(person))
|
||||
.face(person2Face1, (builder) => builder.person({ id: 'person-2' }))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(2);
|
||||
|
||||
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
|
||||
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
|
||||
const person1 = result.people!.find((p) => p.id === 'person-1');
|
||||
const person2 = result.people!.find((p) => p.id === 'person-2');
|
||||
|
||||
expect(person1).toBeDefined();
|
||||
expect(person1!.faces).toHaveLength(2);
|
||||
@@ -173,10 +154,6 @@ describe('mapAsset', () => {
|
||||
|
||||
it('should combine faces of the same person into a single entry', () => {
|
||||
const face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
@@ -186,10 +163,6 @@ describe('mapAsset', () => {
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-2',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
@@ -198,24 +171,21 @@ describe('mapAsset', () => {
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [face1, face2],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [],
|
||||
};
|
||||
const person = PersonFactory.create();
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
const asset = AssetFactory.from()
|
||||
.face(face1, (builder) => builder.person(person))
|
||||
.face(face2, (builder) => builder.person(person))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
|
||||
const person = result.people![0];
|
||||
expect(person.id).toBe(personStub.withName.id);
|
||||
expect(person.faces).toHaveLength(2);
|
||||
expect(result.people![0].id).toBe(person.id);
|
||||
expect(result.people![0].faces).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -487,11 +487,6 @@ export enum MetadataKey {
|
||||
TelemetryEnabled = 'telemetry_enabled',
|
||||
}
|
||||
|
||||
export enum RouteKey {
|
||||
Asset = 'assets',
|
||||
User = 'users',
|
||||
}
|
||||
|
||||
export enum CacheControl {
|
||||
PrivateWithCache = 'private_with_cache',
|
||||
PrivateWithoutCache = 'private_without_cache',
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
@@ -94,10 +94,7 @@ export class MaintenanceWorkerController {
|
||||
@Post('admin/database-backups/upload')
|
||||
@MaintenanceRoute()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||
return this.databaseBackupService.uploadBackup(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import multer from 'multer';
|
||||
import { of } from 'rxjs';
|
||||
import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { fromMaybeArray } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadInterceptor implements NestInterceptor {
|
||||
constructor(private service: AssetMediaService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
||||
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
||||
if (response) {
|
||||
res.status(200);
|
||||
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
export class AssetUploadInterceptor extends UploadInterceptor {
|
||||
constructor(service: AssetMediaService) {
|
||||
super({
|
||||
onRequest: async (req, res) => {
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
||||
const response = await service.onBeforeUpload(req.user, checksum);
|
||||
if (response) {
|
||||
res.status(200);
|
||||
return of(response);
|
||||
}
|
||||
},
|
||||
configure: (instance: multer.Multer) =>
|
||||
instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||
upload: (req, file) => service.onUpload(req.user, file),
|
||||
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||
import { NextFunction, RequestHandler } from 'express';
|
||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Observable } from 'rxjs';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { RouteKey } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
return file ? mapToUploadFile(file) : file;
|
||||
}
|
||||
|
||||
export function getFiles(files: UploadFiles) {
|
||||
return {
|
||||
file: getFile(files, 'assetData') as UploadFile,
|
||||
sidecarFile: getFile(files, 'sidecarData'),
|
||||
};
|
||||
}
|
||||
|
||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
||||
|
||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
||||
|
||||
interface Callback<T> {
|
||||
(error: Error): void;
|
||||
(error: null, result: T): void;
|
||||
}
|
||||
|
||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
||||
try {
|
||||
return callback(null, target());
|
||||
} catch (error: Error | any) {
|
||||
return callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadInterceptor implements NestInterceptor {
|
||||
private handlers: {
|
||||
userProfile: RequestHandler;
|
||||
assetUpload: RequestHandler;
|
||||
};
|
||||
private defaultStorage: StorageEngine;
|
||||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetMediaService,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(FileUploadInterceptor.name);
|
||||
|
||||
this.defaultStorage = diskStorage({
|
||||
filename: this.filename.bind(this),
|
||||
destination: this.destination.bind(this),
|
||||
});
|
||||
|
||||
const instance = multer({
|
||||
fileFilter: this.fileFilter.bind(this),
|
||||
storage: {
|
||||
_handleFile: this.handleFile.bind(this),
|
||||
_removeFile: this.removeFile.bind(this),
|
||||
},
|
||||
});
|
||||
|
||||
this.handlers = {
|
||||
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
|
||||
assetUpload: instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||
const context_ = context.switchToHttp();
|
||||
const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
|
||||
|
||||
const handler: RequestHandler | null = this.getHandler(route as RouteKey);
|
||||
if (handler) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
|
||||
const maybePromise = handler(context_.getRequest(), context_.getResponse(), next);
|
||||
Promise.resolve(maybePromise).catch((error) => reject(error));
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`Skipping invalid file upload route: ${route}`);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
|
||||
request.on('error', (error) => {
|
||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
||||
});
|
||||
|
||||
if (!this.isAssetUploadFile(file)) {
|
||||
this.defaultStorage._handleFile(request, file, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...info, checksum: hash.digest() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.defaultStorage._removeFile(request, file, callback);
|
||||
}
|
||||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private getHandler(route: RouteKey) {
|
||||
switch (route) {
|
||||
case RouteKey.Asset: {
|
||||
return this.handlers.assetUpload;
|
||||
}
|
||||
|
||||
case RouteKey.User: {
|
||||
return this.handlers.userProfile;
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
server/src/middleware/upload.interceptor.ts
Normal file
131
server/src/middleware/upload.interceptor.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException } from '@nestjs/common';
|
||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||
import { NextFunction, RequestHandler, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
type Callback<T> = {
|
||||
(error: Error): void;
|
||||
(error: null, result: T): void;
|
||||
};
|
||||
|
||||
export type UploadFile = {
|
||||
requestId: string;
|
||||
fieldName: string;
|
||||
originalName: string;
|
||||
};
|
||||
|
||||
export type UploadingFile = UploadFile & {
|
||||
stream: Readable;
|
||||
};
|
||||
|
||||
export type UploadedFile = UploadFile & { metadata: UploadMetadata };
|
||||
|
||||
export type UploadMetadata = {
|
||||
/** folder */
|
||||
folder: string;
|
||||
/** k filename */
|
||||
filename: string;
|
||||
/** full path */
|
||||
path: string;
|
||||
size: number;
|
||||
checksum?: Buffer;
|
||||
};
|
||||
|
||||
export type UploadFiles = {
|
||||
assetData: Express.Multer.File[];
|
||||
sidecarData: Express.Multer.File[];
|
||||
};
|
||||
|
||||
export type UploadRequest = AuthenticatedRequest & {
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
type OnRequest = (req: UploadRequest, res: Response) => Promise<Observable<any> | void>;
|
||||
|
||||
const mapUploadFile = (req: UploadRequest, file: Express.Multer.File): UploadFile => {
|
||||
const originalName = req.body?.filename || Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||
return {
|
||||
requestId: req.requestId,
|
||||
fieldName: file.fieldname,
|
||||
originalName,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapUploadedFile = (req: UploadRequest, file: Express.Multer.File): UploadedFile => {
|
||||
return { ...mapUploadFile(req, file), metadata: (file as unknown as UploadedFile).metadata };
|
||||
};
|
||||
|
||||
const handle = <T>(target: () => T | Promise<T>, callback: Callback<T>) => {
|
||||
void Promise.resolve(true)
|
||||
.then(() => target())
|
||||
.then((result) => callback(null, result))
|
||||
.catch((error) => callback(error));
|
||||
};
|
||||
|
||||
export class UploadInterceptor implements NestInterceptor {
|
||||
private handler: RequestHandler;
|
||||
private onRequest: OnRequest;
|
||||
|
||||
constructor(
|
||||
private options: {
|
||||
/** pre-request hook */
|
||||
onRequest?: OnRequest;
|
||||
configure(instance: multer.Multer): RequestHandler;
|
||||
canUpload(req: UploadRequest, file: UploadFile): boolean;
|
||||
upload(req: UploadRequest, file: UploadingFile): Promise<UploadMetadata>;
|
||||
remove(req: UploadRequest, file: UploadedFile): Promise<void>;
|
||||
},
|
||||
) {
|
||||
const storage = { _handleFile: this.handleFile.bind(this), _removeFile: this.removeFile.bind(this) };
|
||||
this.handler = options.configure(multer({ fileFilter: this.canUpload.bind(this), storage }));
|
||||
this.onRequest = options.onRequest ?? (() => Promise.resolve());
|
||||
}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<UploadRequest>();
|
||||
const res = http.getResponse<Response>();
|
||||
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
req.requestId = v4();
|
||||
|
||||
// hook to preempt the request before file upload
|
||||
const response = await this.onRequest(req, res);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
|
||||
const maybePromise = this.handler(req, res, next);
|
||||
Promise.resolve(maybePromise).catch((error) => reject(error));
|
||||
});
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
private canUpload(req: UploadRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return handle(() => this.options.canUpload(req, mapUploadFile(req, file)), callback);
|
||||
}
|
||||
|
||||
private handleFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<UploadMetadata>) {
|
||||
return handle<any>(
|
||||
() =>
|
||||
this.options
|
||||
.upload(req, { ...mapUploadFile(req, file), stream: file.stream })
|
||||
.then((metadata) => ({ metadata })),
|
||||
callback,
|
||||
);
|
||||
}
|
||||
|
||||
private removeFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<void>) {
|
||||
return handle(() => this.options.remove(req, mapUploadedFile(req, file)), callback);
|
||||
}
|
||||
}
|
||||
17
server/src/middleware/user-profile-upload.interceptor.ts
Normal file
17
server/src/middleware/user-profile-upload.interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import multer from 'multer';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserProfileUploadInterceptor extends UploadInterceptor {
|
||||
constructor(service: UserService) {
|
||||
super({
|
||||
configure: (instance: multer.Multer) => instance.single(UploadFieldName.PROFILE_DATA),
|
||||
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||
upload: (req, file) => service.onUpload(req.user, file),
|
||||
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
@@ -11,49 +6,27 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadFile } from 'src/middleware/upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
body: {},
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: '/data/library/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string, body?: UploadBody) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
body: body || {},
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: `/data/admin/${filename}`,
|
||||
originalName: filename,
|
||||
size: 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
const create = (fieldName: UploadFieldName, originalName: string): UploadFile => {
|
||||
return {
|
||||
requestId: newUuid(),
|
||||
fieldName,
|
||||
originalName,
|
||||
};
|
||||
};
|
||||
|
||||
const validImages = [
|
||||
@@ -208,17 +181,17 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
it('should return if checksum is undefined', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined);
|
||||
await expect(sut.onBeforeUpload(authStub.admin)).resolves.toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
@@ -227,7 +200,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
@@ -236,21 +209,17 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
describe('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||
describe(fieldName, () => {
|
||||
for (const filetype of valid) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
expect(sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
expect(() => sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
@@ -265,70 +234,22 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should prefer filename from body over name from path', () => {
|
||||
const pathFilename = 'invalid-file-name';
|
||||
const body = { filename: 'video.mov' };
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename, body))).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should be the original extension for asset upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the xmp extension for sidecar upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
||||
'random-uuid.xmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the original extension for profile upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFolder', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return profile for profile uploads', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
expect.stringContaining('/data/profile/admin_id'),
|
||||
);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile/admin_id'));
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
expect.stringContaining('/data/upload/admin_id/ra/nd'),
|
||||
);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload/admin_id/ra/nd'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsset', () => {
|
||||
it('should throw an error if the quota is exceeded', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
requestId: '1',
|
||||
fieldName: 'assetData',
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
metadata: {
|
||||
uuid: 'random-uuid',
|
||||
path: 'fake_path/asset_1.jpeg',
|
||||
folder: 'fake_path',
|
||||
filename: 'asset_1.jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
size: 42,
|
||||
},
|
||||
};
|
||||
|
||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||
@@ -342,9 +263,9 @@ describe(AssetMediaService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.metadata.size);
|
||||
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
file.metadata.path,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
);
|
||||
@@ -515,28 +436,19 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should download edited file by default when edits exist', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
files: [
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
const editedAsset = AssetFactory.from()
|
||||
.edit()
|
||||
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||
.build();
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
|
||||
|
||||
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, {})).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
path: editedAsset.files[3].path,
|
||||
fileName: editedAsset.originalFileName,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
@@ -544,28 +456,19 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should download edited file when edited=true', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
files: [
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
const editedAsset = AssetFactory.from()
|
||||
.edit()
|
||||
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||
.build();
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
|
||||
|
||||
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
path: editedAsset.files[3].path,
|
||||
fileName: editedAsset.originalFileName,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
@@ -579,7 +482,9 @@ describe(AssetMediaService.name, () => {
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path });
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.adminSharedLink, editedAsset.id, { edited: false })).resolves.toEqual(
|
||||
await expect(
|
||||
sut.downloadOriginal(AuthFactory.from().sharedLink().build(), editedAsset.id, { edited: false }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: fullsizeEdited.path,
|
||||
fileName: editedAsset.originalFileName,
|
||||
@@ -590,25 +495,19 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should download original file when edited=false', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
files: [
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
const editedAsset = AssetFactory.from()
|
||||
.edit()
|
||||
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||
.file({ type: AssetFileType.FullSize, isEdited: true })
|
||||
.build();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual(
|
||||
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: false })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
path: editedAsset.originalPath,
|
||||
fileName: editedAsset.originalFileName,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
@@ -1039,31 +938,4 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onUploadError', () => {
|
||||
it('should queue a job to delete the uploaded file', async () => {
|
||||
const request = {
|
||||
body: {},
|
||||
user: authStub.user1,
|
||||
} as AuthRequest;
|
||||
|
||||
const file = {
|
||||
fieldname: UploadFieldName.ASSET_DATA,
|
||||
originalname: 'image.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
buffer: Buffer.from(''),
|
||||
size: 1000,
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: '/data/upload/user-id/ra/nd/random-uuid.jpg',
|
||||
} as unknown as Express.Multer.File;
|
||||
|
||||
await sut.onUploadError(request, file);
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [expect.stringContaining('/data/upload/user-id/ra/nd/random-uuid.jpg')] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { extname, join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
@@ -31,11 +33,10 @@ import {
|
||||
Permission,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadedFile, UploadFile, UploadingFile, UploadMetadata } from 'src/middleware/upload.interceptor';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -47,8 +48,8 @@ export interface AssetMediaRedirectResponse {
|
||||
|
||||
@Injectable()
|
||||
export class AssetMediaService extends BaseService {
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
async onBeforeUpload(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum || !auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,78 +61,56 @@ export class AssetMediaService extends BaseService {
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
|
||||
canUpload(auth: AuthDto, file: UploadFile): true {
|
||||
requireUploadAccess(auth);
|
||||
|
||||
const filename = body.filename || file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(file.fieldName === UploadFieldName.ASSET_DATA && mimeTypes.isAsset(file.originalName)) ||
|
||||
(file.fieldName === UploadFieldName.SIDECAR_DATA && mimeTypes.isSidecar(file.originalName))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
this.logger.error(`Unsupported file type ${file.originalName}`);
|
||||
throw new BadRequestException(`Unsupported file type ${file.originalName}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
||||
requireUploadAccess(auth);
|
||||
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||
const stream = file.stream;
|
||||
let checksum: Buffer | undefined;
|
||||
let size = 0;
|
||||
|
||||
const extension = extname(body.filename || file.originalName);
|
||||
const hash = createHash('sha1');
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: extension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: extension,
|
||||
};
|
||||
stream
|
||||
.on('data', (chunk: Buffer) => {
|
||||
hash.update(chunk);
|
||||
size += chunk.length;
|
||||
})
|
||||
.on('end', () => (checksum = hash.digest()))
|
||||
.on('error', () => hash.destroy());
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
|
||||
}
|
||||
const extension = file.fieldName === UploadFieldName.ASSET_DATA ? extname(file.originalName) : '.xmp';
|
||||
const filename = sanitize(`${file.requestId}${extension}`);
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, filename);
|
||||
const path = join(folder, filename);
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
await pipeline(stream, this.storageRepository.createWriteStream(path));
|
||||
|
||||
return { filename, folder, path, checksum, size };
|
||||
}
|
||||
|
||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
||||
async onUploadRemove(auth: AuthDto, file: UploadedFile): Promise<void> {
|
||||
await this.storageRepository.unlink(file.metadata.path);
|
||||
}
|
||||
|
||||
async uploadAsset(
|
||||
auth: AuthDto,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.requireAccess({
|
||||
@@ -141,7 +120,7 @@ export class AssetMediaService extends BaseService {
|
||||
ids: [auth.user.id],
|
||||
});
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
this.requireQuota(auth, file.metadata.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
await onBeforeLink(
|
||||
@@ -151,7 +130,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
@@ -163,8 +142,8 @@ export class AssetMediaService extends BaseService {
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
@@ -174,9 +153,9 @@ export class AssetMediaService extends BaseService {
|
||||
throw new Error('Asset not found');
|
||||
}
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
this.requireQuota(auth, file.metadata.size);
|
||||
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.metadata.path);
|
||||
|
||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||
// but the local variable holds the original file data paths.
|
||||
@@ -185,7 +164,7 @@ export class AssetMediaService extends BaseService {
|
||||
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
|
||||
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||
|
||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
@@ -325,18 +304,18 @@ export class AssetMediaService extends BaseService {
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
data: { files: [file.metadata.path, sidecarFile?.metadata.path] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.metadata.checksum!);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
@@ -358,15 +337,15 @@ export class AssetMediaService extends BaseService {
|
||||
private async replaceFileData(
|
||||
assetId: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarPath?: string,
|
||||
): Promise<void> {
|
||||
await this.assetRepository.update({
|
||||
id: assetId,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
checksum: file.metadata.checksum,
|
||||
originalPath: file.metadata.path,
|
||||
type: mimeTypes.assetType(file.metadata.path),
|
||||
originalFileName: file.originalName,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
@@ -383,9 +362,9 @@ export class AssetMediaService extends BaseService {
|
||||
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
|
||||
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, fileSizeInByte: file.size },
|
||||
{ assetId, fileSizeInByte: file.metadata.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({
|
||||
@@ -424,13 +403,13 @@ export class AssetMediaService extends BaseService {
|
||||
return created;
|
||||
}
|
||||
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadedFile, sidecarFile?: UploadedFile) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
checksum: file.metadata.checksum!,
|
||||
originalPath: file.metadata.path,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
@@ -439,7 +418,7 @@ export class AssetMediaService extends BaseService {
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
type: mimeTypes.assetType(file.metadata.path),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
@@ -454,14 +433,14 @@ export class AssetMediaService extends BaseService {
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
path: sidecarFile.metadata.path,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(sidecarFile.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
||||
{ assetId: asset.id, fileSizeInByte: file.metadata.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -586,19 +585,19 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
||||
const asset = AssetFactory.from()
|
||||
.stack({}, (builder) => builder.asset())
|
||||
.build();
|
||||
mocks.stack.delete.mockResolvedValue();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
||||
...assetStub.primaryImage,
|
||||
stack: {
|
||||
id: 'stack-id',
|
||||
primaryAssetId: assetStub.primaryImage.id,
|
||||
assets: [{ id: 'one-asset' }],
|
||||
},
|
||||
...asset,
|
||||
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
|
||||
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
|
||||
});
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -184,13 +183,13 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if asset is part of stack', async () => {
|
||||
const id = assetStub.primaryImage.id;
|
||||
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' });
|
||||
const asset = AssetFactory.from().stack().build();
|
||||
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId });
|
||||
|
||||
const result = await sut.handleSearchDuplicates({ id });
|
||||
const result = await sut.handleSearchDuplicates({ id: asset.id });
|
||||
|
||||
expect(result).toBe(JobStatus.Skipped);
|
||||
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`);
|
||||
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`);
|
||||
});
|
||||
|
||||
it('should skip if asset is not visible', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MapService } from 'src/services/map.service';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -16,36 +16,41 @@ describe(MapService.name, () => {
|
||||
|
||||
describe('getMapMarkers', () => {
|
||||
it('should get geo information of assets', async () => {
|
||||
const asset = assetStub.withLocation;
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
.build();
|
||||
const marker = {
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
lat: asset.exifInfo.latitude!,
|
||||
lon: asset.exifInfo.longitude!,
|
||||
city: asset.exifInfo.city,
|
||||
state: asset.exifInfo.state,
|
||||
country: asset.exifInfo.country,
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, {});
|
||||
const markers = await sut.getMapMarkers(auth, {});
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
});
|
||||
|
||||
it('should include partner assets', async () => {
|
||||
const partner = factory.partner();
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
const auth = AuthFactory.create();
|
||||
const partner = factory.partner({ sharedWithId: auth.user.id });
|
||||
|
||||
const asset = assetStub.withLocation;
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
.build();
|
||||
const marker = {
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
lat: asset.exifInfo.latitude!,
|
||||
lon: asset.exifInfo.longitude!,
|
||||
city: asset.exifInfo.city,
|
||||
state: asset.exifInfo.state,
|
||||
country: asset.exifInfo.country,
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
@@ -62,21 +67,24 @@ describe(MapService.name, () => {
|
||||
});
|
||||
|
||||
it('should include assets from shared albums', async () => {
|
||||
const asset = assetStub.withLocation;
|
||||
const auth = AuthFactory.create(userStub.user1);
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
.build();
|
||||
const marker = {
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
lat: asset.exifInfo.latitude!,
|
||||
lon: asset.exifInfo.longitude!,
|
||||
city: asset.exifInfo.city,
|
||||
state: asset.exifInfo.state,
|
||||
country: asset.exifInfo.country,
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
|
||||
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true });
|
||||
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { MediaService } from 'src/services/media.service';
|
||||
import { JobCounts, RawImageInfo } from 'src/types';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
||||
@@ -205,7 +204,8 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue assets with edits but missing edited thumbnails', async () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
||||
const asset = AssetFactory.from().edit().build();
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
@@ -213,7 +213,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
data: { id: assetStub.withCropEdit.id },
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -221,8 +221,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should not queue assets with missing edited fullsize when feature is disabled', async () => {
|
||||
const asset = AssetFactory.from().edit().build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
@@ -251,7 +252,8 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
|
||||
const asset = AssetFactory.from().edit().build();
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
@@ -259,11 +261,11 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetGenerateThumbnails,
|
||||
data: { id: assetStub.withCropEdit.id },
|
||||
data: { id: asset.id },
|
||||
},
|
||||
{
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
data: { id: assetStub.withCropEdit.id },
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1504,7 +1506,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, {
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
||||
colorspace: Colorspace.P3,
|
||||
orientation: undefined,
|
||||
processInvalidImages: false,
|
||||
@@ -2193,7 +2195,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
||||
const asset = assetStub.hasEncodedVideo;
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
@@ -1227,16 +1226,17 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.person.updateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply metadata face tags creating new persons', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
it('should apply metadata face tags creating new people', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: personStub.withName.name }),
|
||||
]);
|
||||
@@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => {
|
||||
[
|
||||
{
|
||||
id: 'random-uuid',
|
||||
assetId: assetStub.primaryImage.id,
|
||||
assetId: asset.id,
|
||||
personId: 'random-uuid',
|
||||
imageHeight: 100,
|
||||
imageWidth: 1000,
|
||||
@@ -1258,7 +1258,7 @@ describe(MetadataService.name, () => {
|
||||
[],
|
||||
);
|
||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
||||
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
|
||||
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -1269,21 +1269,22 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should assign metadata face tags to existing persons', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||
mocks.person.createAll.mockResolvedValue([]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||
expect(mocks.person.createAll).not.toHaveBeenCalled();
|
||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: 'random-uuid',
|
||||
assetId: assetStub.primaryImage.id,
|
||||
assetId: asset.id,
|
||||
personId: personStub.withName.id,
|
||||
imageHeight: 100,
|
||||
imageWidth: 1000,
|
||||
@@ -1353,16 +1354,17 @@ describe(MetadataService.name, () => {
|
||||
'should transform RegionInfo geometry according to exif orientation $description',
|
||||
async ({ orientation, expected }) => {
|
||||
const { imgW, imgH, x1, x2, y1, y2 } = expected;
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, {
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
|
||||
withHidden: true,
|
||||
});
|
||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
||||
@@ -1372,7 +1374,7 @@ describe(MetadataService.name, () => {
|
||||
[
|
||||
{
|
||||
id: 'random-uuid',
|
||||
assetId: assetStub.primaryImage.id,
|
||||
assetId: asset.id,
|
||||
personId: 'random-uuid',
|
||||
imageWidth: imgW,
|
||||
imageHeight: imgH,
|
||||
@@ -1386,7 +1388,7 @@ describe(MetadataService.name, () => {
|
||||
[],
|
||||
);
|
||||
expect(mocks.person.updateAll).toHaveBeenCalledWith([
|
||||
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
|
||||
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
|
||||
@@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -64,16 +65,18 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('getExploreData', () => {
|
||||
it('should get assets by city and tag', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
.build();
|
||||
mocks.asset.getAssetIdByCity.mockResolvedValue({
|
||||
fieldName: 'exifInfo.city',
|
||||
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
|
||||
items: [{ value: 'city', data: asset.id }],
|
||||
});
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
|
||||
const expectedResponse = [
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
|
||||
];
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
|
||||
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
|
||||
|
||||
const result = await sut.getExploreData(authStub.user1);
|
||||
const result = await sut.getExploreData(auth);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { stackStub } from 'test/fixtures/asset.stub';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { StackFactory } from 'test/factories/stack.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -20,12 +21,14 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('search', () => {
|
||||
it('should search stacks', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
mocks.stack.search.mockResolvedValue([stackStub('stack-id', [asset])]);
|
||||
const stack = StackFactory.from().primaryAsset(asset).build();
|
||||
mocks.stack.search.mockResolvedValue([stack]);
|
||||
|
||||
await sut.search(authStub.admin, { primaryAssetId: asset.id });
|
||||
await sut.search(auth, { primaryAssetId: asset.id });
|
||||
expect(mocks.stack.search).toHaveBeenCalledWith({
|
||||
ownerId: authStub.admin.user.id,
|
||||
ownerId: auth.user.id,
|
||||
primaryAssetId: asset.id,
|
||||
});
|
||||
});
|
||||
@@ -33,8 +36,10 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should require asset.update permissions', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
|
||||
|
||||
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
@@ -43,18 +48,22 @@ describe(StackService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
|
||||
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
||||
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
|
||||
id: 'stack-id',
|
||||
mocks.stack.create.mockResolvedValue(stack);
|
||||
|
||||
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
|
||||
id: stack.id,
|
||||
primaryAssetId: primaryAsset.id,
|
||||
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
||||
});
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
|
||||
stackId: 'stack-id',
|
||||
userId: authStub.admin.user.id,
|
||||
stackId: stack.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
|
||||
});
|
||||
@@ -78,23 +87,26 @@ describe(StackService.name, () => {
|
||||
});
|
||||
|
||||
it('should get stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
|
||||
await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
|
||||
id: 'stack-id',
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
|
||||
await expect(sut.get(auth, stack.id)).resolves.toEqual({
|
||||
id: stack.id,
|
||||
primaryAssetId: primaryAsset.id,
|
||||
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
|
||||
});
|
||||
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should require stack.update permissions', async () => {
|
||||
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.getById).not.toHaveBeenCalled();
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
@@ -104,7 +116,7 @@ describe(StackService.name, () => {
|
||||
it('should fail if stack could not be found', async () => {
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error);
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error);
|
||||
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
@@ -112,57 +124,64 @@ describe(StackService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if the provided primary asset id is not in the stack', async () => {
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
||||
const auth = AuthFactory.create();
|
||||
const stack = StackFactory.from().primaryAsset().asset().build();
|
||||
|
||||
await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
|
||||
await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
||||
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
|
||||
await sut.update(authStub.admin, 'stack-id', { primaryAssetId: asset.id });
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
mocks.stack.update.mockResolvedValue(stack);
|
||||
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', {
|
||||
id: 'stack-id',
|
||||
await sut.update(auth, stack.id, { primaryAssetId: asset.id });
|
||||
|
||||
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
|
||||
expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, {
|
||||
id: stack.id,
|
||||
primaryAssetId: asset.id,
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
|
||||
stackId: 'stack-id',
|
||||
userId: authStub.admin.user.id,
|
||||
stackId: stack.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require stack.delete permissions', async () => {
|
||||
await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
|
||||
mocks.stack.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(authStub.admin, 'stack-id');
|
||||
await sut.delete(auth, 'stack-id');
|
||||
|
||||
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', {
|
||||
stackId: 'stack-id',
|
||||
userId: authStub.admin.user.id,
|
||||
userId: auth.user.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Stats } from 'node:fs';
|
||||
import { defaults, SystemConfig } from 'src/config';
|
||||
import { AssetPathType, JobStatus } from 'src/enum';
|
||||
import { AssetPathType, AssetType, JobStatus } from 'src/enum';
|
||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getForStorageTemplate } from 'test/mappers';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const motionAsset = assetStub.storageAsset({});
|
||||
const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id });
|
||||
|
||||
describe(StorageTemplateService.name, () => {
|
||||
let sut: StorageTemplateService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should migrate single moving picture', async () => {
|
||||
const motionAsset = AssetFactory.from({
|
||||
type: AssetType.Video,
|
||||
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
const stillAsset = AssetFactory.from({
|
||||
livePhotoVideoId: motionAsset.id,
|
||||
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
@@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should use handlebar if condition for album', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const user = userStub.user1;
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const album = AlbumFactory.from().asset().build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
||||
@@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => {
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||
|
||||
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
||||
@@ -166,14 +179,14 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should use handlebar else condition for album', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const user = userStub.user1;
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
|
||||
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
||||
|
||||
@@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle album startDate', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const user = userStub.user1;
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const album = AlbumFactory.from().asset().build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template =
|
||||
@@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => {
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle else condition from album startDate', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const user = userStub.user1;
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template =
|
||||
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
|
||||
@@ -234,7 +247,7 @@ describe(StorageTemplateService.name, () => {
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
|
||||
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
||||
|
||||
@@ -248,11 +261,18 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should migrate previously failed move from original path when it still exists', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
const asset = assetStub.storageAsset();
|
||||
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
|
||||
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
const previousFailedNewPath = `/data/library/${user.id}/2023/Feb/${asset.originalFileName}`;
|
||||
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
|
||||
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
|
||||
mocks.move.getByEntity.mockResolvedValue({
|
||||
@@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => {
|
||||
oldPath: asset.originalPath,
|
||||
newPath: previousFailedNewPath,
|
||||
});
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||
mocks.move.update.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
@@ -288,9 +308,16 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif({ fileSizeInByte: 5000 })
|
||||
.build();
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
|
||||
const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
|
||||
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
|
||||
@@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => {
|
||||
oldPath: asset.originalPath,
|
||||
newPath: previousFailedNewPath,
|
||||
});
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||
mocks.move.update.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
@@ -325,45 +352,53 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail move if copying and hash of asset and the new file do not match', async () => {
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
|
||||
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
|
||||
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: testAsset.id,
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: testAsset.originalPath,
|
||||
oldPath: asset.originalPath,
|
||||
newPath,
|
||||
});
|
||||
|
||||
await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success);
|
||||
await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id);
|
||||
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath);
|
||||
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||
entityId: testAsset.id,
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: testAsset.originalPath,
|
||||
oldPath: asset.originalPath,
|
||||
newPath,
|
||||
});
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath);
|
||||
expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath);
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath);
|
||||
expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const testAsset = assetStub.storageAsset();
|
||||
const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build();
|
||||
|
||||
it.each`
|
||||
failedPathChecksum | failedPathSize | reason
|
||||
${testAsset.checksum} | ${500} | ${'file size'}
|
||||
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'}
|
||||
failedPathChecksum | failedPathSize | reason
|
||||
${testAsset.checksum} | ${500} | ${'file size'}
|
||||
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.exifInfo.fileSizeInByte} | ${'checksum'}
|
||||
`(
|
||||
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
|
||||
async ({ failedPathChecksum, failedPathSize }) => {
|
||||
@@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => {
|
||||
oldPath: testAsset.originalPath,
|
||||
newPath: previousFailedNewPath,
|
||||
});
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset));
|
||||
mocks.move.update.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: testAsset.id,
|
||||
@@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle an asset with a duplicate destination', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
const oldPath = asset.originalPath;
|
||||
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
|
||||
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
const newPath2 = newPath.replace('.jpg', '+1.jpg');
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip when an asset already matches the template', async () => {
|
||||
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' });
|
||||
const asset = AssetFactory.from({
|
||||
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
|
||||
await sut.handleMigration();
|
||||
@@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip when an asset is probably a duplicate', async () => {
|
||||
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' });
|
||||
const asset = AssetFactory.from({
|
||||
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg',
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
|
||||
await sut.handleMigration();
|
||||
@@ -471,10 +519,15 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should move an asset', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
const oldPath = asset.originalPath;
|
||||
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should use the user storage label', async () => {
|
||||
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
||||
const asset = assetStub.storageAsset({ ownerId: user.id });
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
const user = UserFactory.create({ storageLabel: 'label-1' });
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
asset.originalPath,
|
||||
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
@@ -520,10 +579,16 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
|
||||
const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 });
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
originalPath: '/path/to/original.jpg',
|
||||
})
|
||||
.exif({ fileSizeInByte: 5000 })
|
||||
.build();
|
||||
|
||||
const oldPath = asset.originalPath;
|
||||
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
@@ -561,10 +626,17 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
entityId: asset.id,
|
||||
@@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
|
||||
asset.originalPath,
|
||||
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
|
||||
);
|
||||
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
|
||||
asset.originalPath,
|
||||
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
|
||||
);
|
||||
expect(mocks.storage.stat).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
|
||||
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
|
||||
);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update the database if the move fails', async () => {
|
||||
const asset = assetStub.storageAsset();
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.storage.rename.mockRejectedValue(new Error('Read only system'));
|
||||
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system'));
|
||||
mocks.move.create.mockResolvedValue({
|
||||
@@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => {
|
||||
oldPath: asset.originalPath,
|
||||
newPath: '',
|
||||
});
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||
expect(mocks.storage.rename).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
|
||||
asset.originalPath,
|
||||
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
|
||||
);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should migrate live photo motion video alongside the still image', async () => {
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
|
||||
const motionAsset = AssetFactory.from({
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
const stillAsset = AssetFactory.from({
|
||||
livePhotoVideoId: motionAsset.id,
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset]));
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
@@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
describe('file rename correctness', () => {
|
||||
it('should not create double extensions when filename has lower extension', async () => {
|
||||
const user = factory.userAdmin({ storageLabel: 'label-1' });
|
||||
const asset = assetStub.storageAsset({
|
||||
ownerId: user.id,
|
||||
const user = UserFactory.create({ storageLabel: 'label-1' });
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
|
||||
originalFileName: 'IMG_7065.HEIC',
|
||||
});
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should not create double extensions when filename has uppercase extension', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const asset = assetStub.storageAsset({
|
||||
ownerId: user.id,
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
|
||||
originalFileName: 'IMG_7065.HEIC',
|
||||
});
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
})
|
||||
.owner(user)
|
||||
.exif({ fileSizeInByte: 12_345 })
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const asset = assetStub.storageAsset({
|
||||
ownerId: user.id,
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
|
||||
originalFileName: 'IMG_7065.JPEG',
|
||||
});
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
@@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
|
||||
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const asset = assetStub.storageAsset({
|
||||
ownerId: user.id,
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
|
||||
originalFileName: 'IMG_7065.JPG',
|
||||
});
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
|
||||
})
|
||||
.owner(user)
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.move.create.mockResolvedValue({
|
||||
id: '123',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Updateable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { extname, join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
@@ -11,15 +14,41 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { UploadFile, UploadMetadata, UploadedFile, UploadingFile } from 'src/middleware/upload.interceptor';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
export class UserService extends BaseService {
|
||||
canUpload(auth: AuthDto, file: UploadFile) {
|
||||
return mimeTypes.isProfile(file.originalName);
|
||||
}
|
||||
|
||||
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||
const extension = extname(file.originalName);
|
||||
const filename = sanitize(`${file.requestId}${extension}`);
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Profile, auth.user.id, filename);
|
||||
const path = join(folder, filename);
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
let size = 0;
|
||||
file.stream.on('data', (chunk: Buffer) => (size += chunk.length));
|
||||
|
||||
await pipeline(file.stream, this.storageRepository.createWriteStream(path));
|
||||
|
||||
return { filename, folder, path, size };
|
||||
}
|
||||
|
||||
async onUploadRemove(auth: AuthDto, file: UploadedFile) {
|
||||
await this.storageRepository.unlink(file.metadata.path);
|
||||
}
|
||||
|
||||
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -90,11 +119,11 @@ export class UserService extends BaseService {
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||
async createProfileImage(auth: AuthDto, file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, {
|
||||
profileImagePath: file.path,
|
||||
profileImagePath: file.metadata.path,
|
||||
profileChangedAt: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||
import {
|
||||
@@ -420,37 +418,6 @@ export interface VectorUpdateResult {
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface ImmichFile extends Express.Multer.File {
|
||||
uuid: string;
|
||||
/** sha1 hash of file */
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
uuid: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadBody {
|
||||
filename?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type UploadRequest = {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
body: UploadBody;
|
||||
};
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
export interface IBulkAsset {
|
||||
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
|
||||
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
|
||||
|
||||
@@ -2,16 +2,14 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
||||
import { IBulkAsset } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
|
||||
@@ -186,25 +184,6 @@ export const onAfterUnlink = async (
|
||||
await eventRepository.emit('AssetShow', { assetId: livePhotoVideoId, userId });
|
||||
};
|
||||
|
||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
return {
|
||||
uuid: file.uuid,
|
||||
checksum: file.checksum,
|
||||
originalPath: file.path,
|
||||
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
|
||||
return {
|
||||
auth: request.user || null,
|
||||
body: request.body,
|
||||
fieldName: file.fieldname as UploadFieldName,
|
||||
file: mapToUploadFile(file as ImmichFile),
|
||||
};
|
||||
};
|
||||
|
||||
const isFlipped = (orientation?: string | null) => {
|
||||
const value = Number(orientation);
|
||||
return value && [5, 6, 7, 8, -90, 90].includes(value);
|
||||
|
||||
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 { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
|
||||
import { AssetExifFactory } from 'test/factories/asset-exif.factory';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { StackFactory } from 'test/factories/stack.factory';
|
||||
import {
|
||||
AssetEditLike,
|
||||
AssetExifLike,
|
||||
AssetFaceLike,
|
||||
AssetFileLike,
|
||||
AssetLike,
|
||||
FactoryBuilder,
|
||||
StackLike,
|
||||
UserLike,
|
||||
} from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
@@ -15,7 +26,8 @@ export class AssetFactory {
|
||||
#assetExif?: AssetExifFactory;
|
||||
#files: AssetFileFactory[] = [];
|
||||
#edits: AssetEditFactory[] = [];
|
||||
#faces: Selectable<AssetFaceTable>[] = [];
|
||||
#faces: AssetFaceFactory[] = [];
|
||||
#stack?: Selectable<StackTable> & { assets: Selectable<AssetTable>[]; primaryAsset: Selectable<AssetTable> };
|
||||
|
||||
private constructor(private readonly value: Selectable<AssetTable>) {
|
||||
value.ownerId ??= newUuid();
|
||||
@@ -83,8 +95,8 @@ export class AssetFactory {
|
||||
return this;
|
||||
}
|
||||
|
||||
face(dto: Selectable<AssetFaceTable>) {
|
||||
this.#faces.push(dto);
|
||||
face(dto: AssetFaceLike = {}, builder?: FactoryBuilder<AssetFaceFactory>) {
|
||||
this.#faces.push(build(AssetFaceFactory.from(dto), builder));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -117,6 +129,12 @@ export class AssetFactory {
|
||||
return this;
|
||||
}
|
||||
|
||||
stack(dto: StackLike = {}, builder?: FactoryBuilder<StackFactory>) {
|
||||
this.#stack = build(StackFactory.from(dto).primaryAsset(this.value), builder).build();
|
||||
this.value.stackId = this.#stack.id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
const exif = this.#assetExif?.build();
|
||||
|
||||
@@ -126,8 +144,9 @@ export class AssetFactory {
|
||||
exifInfo: exif as NonNullable<typeof exif>,
|
||||
files: this.#files.map((file) => file.build()),
|
||||
edits: this.#edits.map((edit) => edit.build()),
|
||||
faces: this.#faces,
|
||||
stack: null,
|
||||
faces: this.#faces.map((face) => face.build()),
|
||||
stack: this.#stack ?? null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
|
||||
@@ -18,3 +21,6 @@ export type AlbumLike = Partial<Selectable<AlbumTable>>;
|
||||
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
|
||||
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
|
||||
export type UserLike = Partial<Selectable<UserTable>>;
|
||||
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||
export type StackLike = Partial<Selectable<StackTable>>;
|
||||
|
||||
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 { previewFile } from 'test/fixtures/asset.stub';
|
||||
import { AssetFileType, AssetType } from 'src/enum';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
|
||||
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
||||
@@ -179,7 +179,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.jpg',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
newThumbnailMiddle: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -192,7 +192,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.jpg',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
newThumbnailEnd: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -205,7 +205,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.jpg',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
rawEmbeddedThumbnail: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -218,7 +218,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.dng',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
negativeCoordinate: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -231,7 +231,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.jpg',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
overflowingCoordinate: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -244,7 +244,7 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Image,
|
||||
originalPath: '/original/path.jpg',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
videoThumbnail: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
@@ -257,6 +257,6 @@ export const personThumbnailStub = {
|
||||
type: AssetType.Video,
|
||||
originalPath: '/original/path.mp4',
|
||||
exifOrientation: '1',
|
||||
previewPath: previewFile.path,
|
||||
previewPath: AssetFileFactory.create({ type: AssetFileType.Preview }).path,
|
||||
}),
|
||||
};
|
||||
|
||||
22
server/test/mappers.ts
Normal file
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,
|
||||
};
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import postgres from 'postgres';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
@@ -120,7 +119,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
|
||||
...providers,
|
||||
],
|
||||
})
|
||||
.overrideInterceptor(FileUploadInterceptor)
|
||||
.overrideInterceptor(AssetUploadInterceptor)
|
||||
.useValue(memoryFileInterceptor)
|
||||
.overrideInterceptor(AssetUploadInterceptor)
|
||||
.useValue(noopInterceptor)
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
: slideshowLookCssMapping[$slideshowLook]} checkerboard"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
@@ -259,4 +259,8 @@
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
.checkerboard {
|
||||
background-image: conic-gradient(#808080 25%, #b0b0b0 25% 50%, #808080 50% 75%, #b0b0b0 75%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -443,54 +443,50 @@
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
const startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
const endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
|
||||
|
||||
if (startBucket === null || endBucket === null) {
|
||||
if (!startBucket || !endBucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const monthGroups = timelineManager.months;
|
||||
const startBucketIndex = monthGroups.indexOf(startBucket);
|
||||
const endBucketIndex = monthGroups.indexOf(endBucket);
|
||||
|
||||
if (startBucketIndex === -1 || endBucketIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStartIndex = Math.min(startBucketIndex, endBucketIndex);
|
||||
const rangeEndIndex = Math.max(startBucketIndex, endBucketIndex);
|
||||
|
||||
// Select/deselect assets in range (start,end)
|
||||
let started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
if (started) {
|
||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||
for (const asset of monthGroup.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||
} else {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
for (let index = rangeStartIndex + 1; index < rangeEndIndex; index++) {
|
||||
const monthGroup = monthGroups[index];
|
||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||
for (const monthAsset of monthGroup.assetsIterator()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(monthAsset.id);
|
||||
} else {
|
||||
handleSelectAsset(monthAsset);
|
||||
}
|
||||
}
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update date group selection in range [start,end]
|
||||
started = false;
|
||||
for (const monthGroup of timelineManager.months) {
|
||||
if (monthGroup === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (started) {
|
||||
// Split month group into day groups and check each group
|
||||
for (const dayGroup of monthGroup.dayGroups) {
|
||||
const dayGroupTitle = dayGroup.groupTitle;
|
||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||
}
|
||||
for (let index = rangeStartIndex; index <= rangeEndIndex; index++) {
|
||||
const monthGroup = monthGroups[index];
|
||||
|
||||
// Split month group into day groups and check each group
|
||||
for (const dayGroup of monthGroup.dayGroups) {
|
||||
const dayGroupTitle = dayGroup.groupTitle;
|
||||
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
|
||||
}
|
||||
}
|
||||
if (monthGroup === endBucket) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,8 +340,8 @@ export const langs: Lang[] = [
|
||||
{
|
||||
name: 'Chinese (Simplified)',
|
||||
code: 'zh-CN',
|
||||
weblateCode: 'zh_SIMPLIFIED',
|
||||
loader: () => import('$i18n/zh_SIMPLIFIED.json'),
|
||||
weblateCode: 'zh_Hans',
|
||||
loader: () => import('$i18n/zh_Hans.json'),
|
||||
},
|
||||
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({ default: {} }) },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user