Compare commits

..

17 Commits

Author SHA1 Message Date
Alex Tran
7277ea3d7a feat(server): asset_user table 2025-01-28 22:04:21 -06:00
Carsten Otto
da580d4685 fix: show local dates for range in album summary (#15654)
* fix(web): show local dates for range in album summary

* fix(server): show local dates for range in album summary
2025-01-28 14:33:38 -06:00
Simon
cb6d94c7a7 chore: update of the Ukrainian translation (#15751)
Update uk-UA.json

Update of the Ukrainian translation for the Immich app
2025-01-28 20:32:57 +00:00
André Ventura
060300de8a fix(web): cancel people merge selection: do not show "Change name successfully" notification (#15744)
fix(web): cancel people merge selection: do not show "Change name successfully" notification.

Co-authored-by: André Ventura <afv@users.noreply.github.com>
2025-01-28 11:43:52 -06:00
Miguel Angel Nubla
c2ba1cc202 docs: add immich-upload-optimizer to Community Projects list (#15738) 2025-01-28 09:40:00 -06:00
PastLeo
08db77db23 feat: resolution selection and default preview playback for 360° panorama videos (#15747)
* original/preview switching in photo-sphere-viewer

1. default to preview in photo-sphere-viewer video mode
2. install and integrate @photo-sphere-viewer/settings-plugin & @photo-sphere-viewer/resolution-plugin

* fix lint errors
2025-01-28 09:09:40 -06:00
RiggiG
92dff839d0 fix(web): do not throw error when hash fails (#15740)
change: do not throw error when hash fails
2025-01-28 03:54:56 +00:00
Christian Kündig
fe1e09e51f fix(server): Allow negative rating (for rejected images) (#15699)
Allow negative rating (for rejected images)
2025-01-27 21:54:29 -06:00
github-actions
f44669447f chore: version v1.125.6 2025-01-28 02:58:27 +00:00
Mert
92412ca2f7 fix(server): person thumbnail generation always being queued (#15734)
* fix person thumbnail generation always being queued

* fix thumbhash comparison

* fix mock
2025-01-27 16:20:18 -06:00
github-actions
64d926581f chore: version v1.125.5 2025-01-27 20:04:50 +00:00
Alex
c139e05170 fix(mobile): locale option causes the datetime filter error out (#15704) 2025-01-27 14:02:23 -06:00
Alex
0fe62298e1 fix(server): duplicate detection (#15727) 2025-01-27 13:53:59 -06:00
github-actions
e5794e6cfc chore: version v1.125.4 2025-01-27 18:44:12 +00:00
Alex
f6cbc9db06 fix(server): cannot render album page when all assets of an album are in trash (#15690)
* fix(server): cannot render album page when all assets of an album are in trash

* inner join

* add e2e test

* check empty albums too

* render add to album button on empty album

* lint

* count 0 if undefined

* fix album card test

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-01-26 21:18:34 -06:00
Alex
8dab5d3798 chore(mobile): post release task (#15662) 2025-01-26 15:09:15 -06:00
Carsten Otto
e864811a85 fix(web): sort folders (#15691)
fixes #13145
2025-01-26 15:07:22 -06:00
57 changed files with 401 additions and 208 deletions

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -99,6 +99,11 @@ const projects: CommunityProjectProps[] = [
description: 'Downloads a configurable number of random photos based on people or album ID.',
url: 'https://github.com/jon6fingrs/immich-dl',
},
{
title: 'Immich Upload Optimizer',
description: 'Automatically optimize files uploaded to Immich in order to save storage space',
url: 'https://github.com/miguelangel-nubla/immich-upload-optimizer',
},
];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {

View File

@@ -1,4 +1,16 @@
[
{
"label": "v1.125.6",
"url": "https://v1.125.6.archive.immich.app"
},
{
"label": "v1.125.5",
"url": "https://v1.125.5.archive.immich.app"
},
{
"label": "v1.125.4",
"url": "https://v1.125.4.archive.immich.app"
},
{
"label": "v1.125.3",
"url": "https://v1.125.3.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -22,82 +22,92 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2, user3] = await Promise.all([
[user1, user2, user3, user4] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
[user1Asset1, user1Asset2] = await Promise.all([
[user1Asset1, user1Asset2, user4Asset1] = await Promise.all([
utils.createAsset(user1.accessToken, { isFavorite: true }),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
user1Albums = await Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
[user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
}),
]),
Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]),
utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }),
utils.createAlbum(user4.accessToken, { albumName: user4Empty }),
utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
}),
]);
user2Albums = await Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]);
await utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
});
await addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
);
user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) });
await Promise.all([
addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
),
addAssetsToAlbum(
{ id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } },
{ headers: asBearerAuth(user4.accessToken) },
),
// add shared link to user1SharedLink album
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
@@ -110,7 +120,11 @@ describe('/albums', () => {
}),
]);
await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
[user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),
utils.deleteAssets(user1.accessToken, [user4Asset1.id]),
]);
});
describe('GET /albums', () => {
@@ -287,6 +301,25 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toHaveLength(5);
});
it('should return empty albums and albums where all assets are deleted', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
shared: false,
}),
]),
);
});
});
describe('GET /albums/:id', () => {

View File

@@ -701,6 +701,20 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
it('should set the negative rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: -1 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: -1,
}),
});
expect(status).toEqual(200);
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(app)

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.125.3"
version = "1.125.6"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 179,
"android.injected.version.name" => "1.125.3",
"android.injected.version.code" => 182,
"android.injected.version.name" => "1.125.6",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -7,10 +7,10 @@
"action_common_select": "Вибрати",
"action_common_update": "Оновити",
"add_a_name": "Додати ім'я",
"add_endpoint": "Add endpoint",
"add_endpoint": "Додати кінцеву точку",
"add_to_album_bottom_sheet_added": "Додано до {album}",
"add_to_album_bottom_sheet_already_exists": "Вже є в {album}",
"advanced_settings_log_level_title": "Log level: {}",
"advanced_settings_log_level_title": "Рівень журналу: {}",
"advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.",
"advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням",
"advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.",
@@ -66,12 +66,12 @@
"assets_restored_successfully": "{} елемент(и) успішно відновлено",
"assets_trashed": "{} елемент(и) поміщено до кошика",
"assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich",
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings",
"asset_viewer_settings_subtitle": "Керувати налаштуваннями переглядача галереї",
"asset_viewer_settings_title": "Переглядач зображень",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
"automatic_endpoint_switching_title": "Automatic URL switching",
"background_location_permission": "Background location permission",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
"automatic_endpoint_switching_subtitle": "Підключатися локально через визначену Wi-Fi мережу, коли вона доступна, і використовувати альтернативні з'єднання в інших випадках",
"automatic_endpoint_switching_title": "Автоперемикання URL-адрес",
"background_location_permission": "Дозвіл на місцезнаходження у фоні",
"background_location_permission_content": "Щоб перемикатися між мережами під час роботи у фоновому режимі, Immich *завжди* повинен мати доступ до точного місцезнаходження, щоб додаток міг зчитувати назву Wi-Fi мережі",
"backup_album_selection_page_albums_device": "Альбоми на пристрої ({})",
"backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити",
"backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.",
@@ -137,7 +137,7 @@
"backup_manual_success": "Успіх",
"backup_manual_title": "Стан завантаження",
"backup_options_page_title": "Резервне копіювання",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_setting_subtitle": "Керування завантаженням у фоновому та передньому режимах",
"cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)",
"cache_settings_clear_cache_button": "Очистити кеш",
"cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.",
@@ -156,17 +156,17 @@
"cache_settings_tile_subtitle": "Керування поведінкою локального сховища",
"cache_settings_tile_title": "Локальне сховище",
"cache_settings_title": "Налаштування кешування",
"cancel": "Cancel",
"canceled": "Canceled",
"change_display_order": "Change display order",
"cancel": "Скасувати",
"canceled": "Скасовано",
"change_display_order": "Змінити порядок відображення",
"change_password_form_confirm_password": "Підтвердити пароль",
"change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.",
"change_password_form_new_password": "Новий пароль",
"change_password_form_password_mismatch": "Паролі не співпадають",
"change_password_form_reenter_new_password": "Повторіть новий пароль",
"check_corrupt_asset_backup": "Check for corrupt asset backups",
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_corrupt_asset_backup": "Перевірити пошкоджені резервні копії",
"check_corrupt_asset_backup_button": "Виконати перевірку",
"check_corrupt_asset_backup_description": "Виконуйте цю перевірку лише через Wi-Fi та після того, як усі активи будуть заархівовані. Процес може зайняти кілька хвилин.",
"client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Введіть пароль",
"client_cert_import": "Імпорт",
@@ -181,7 +181,7 @@
"common_create_new_album": "Створити новий альбом",
"common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.",
"common_shared": "Спільні",
"completed": "Completed",
"completed": "Завершено",
"contextual_search": "Схід сонця на пляжі",
"control_bottom_app_bar_add_to_album": "Додати у альбом",
"control_bottom_app_bar_album_info": "{} елементи",
@@ -199,7 +199,7 @@
"control_bottom_app_bar_share": "Поділитися",
"control_bottom_app_bar_share_to": "Поділитися",
"control_bottom_app_bar_stack": "Стек",
"control_bottom_app_bar_trash_from_immich": "Перемістити до кошика",
"control_bottom_app_bar_trash_from_immich": "В кошик",
"control_bottom_app_bar_unarchive": "Розархівувати",
"control_bottom_app_bar_unfavorite": "Видалити з улюблених",
"control_bottom_app_bar_upload": "Завантажити",
@@ -213,7 +213,7 @@
"crop": "Кадрувати",
"curated_location_page_title": "Місця",
"curated_object_page_title": "Речі",
"current_server_address": "Current server address",
"current_server_address": "Поточна адреса сервера",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a",
@@ -250,10 +250,10 @@
"edit_date_time_dialog_timezone": "Часовий пояс",
"edit_image_title": "Редагувати",
"edit_location_dialog_title": "Місцезнаходження",
"end_date": "End date",
"enqueued": "Enqueued",
"enter_wifi_name": "Enter WiFi name",
"error_change_sort_album": "Failed to change album sort order",
"end_date": "Дата завершення",
"enqueued": "В черзі",
"enter_wifi_name": "Введіть назву Wi-Fi",
"error_change_sort_album": "Не вдалося змінити порядок сортування альбому",
"error_saving_image": "Помилка: {}",
"exif_bottom_sheet_description": "Додати опис...",
"exif_bottom_sheet_details": "ПОДРОБИЦІ",
@@ -265,16 +265,16 @@
"experimental_settings_new_asset_list_title": "Експериментальний макет знімків",
"experimental_settings_subtitle": "На власний ризик!",
"experimental_settings_title": "Експериментальні",
"external_network": "External network",
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"failed": "Failed",
"external_network": "Зовнішня мережа",
"external_network_sheet_info": "Якщо ви не підключені до бажаної Wi-Fi мережі, додаток підключиться до сервера через першу доступну URL-адресу зверху вниз",
"failed": "Не вдалося",
"favorites": "Вибране",
"favorites_page_no_favorites": "Немає улюблених елементів",
"favorites_page_title": "Улюблені",
"filename_search": "Ім'я або розширення файлу",
"filter": "Фільтр",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"grant_permission": "Grant permission",
"get_wifiname_error": "Не вдалося отримати назву Wi-Fi мережі. Переконайтеся, що ви надали необхідні дозволи та підключені до Wi-Fi мережі",
"grant_permission": "Надати дозвіл",
"haptic_feedback_switch": "Увімкнути тактильну віддачу",
"haptic_feedback_title": "Тактильна віддача",
"header_settings_add_header_tip": "Додати заголовок",
@@ -320,10 +320,10 @@
"library_page_sort_most_oldest_photo": "Найдавніші фото",
"library_page_sort_most_recent_photo": "Найновіші фото",
"library_page_sort_title": "Назва альбому",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location_permission": "Location permission",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name",
"local_network": "Локальна мережа",
"local_network_sheet_info": "Додаток підключатиметься до сервера через цю URL-адресу при використанні вказаної Wi-Fi мережі",
"location_permission": "Дозвіл до місцезнаходження",
"location_permission_content": "Для використання функції автоперемикання Immich потрібен дозвіл на точне місцезнаходження, щоб зчитувати назву поточної Wi-Fi мережі",
"location_picker_choose_on_map": "Обрати на мапі",
"location_picker_latitude": "Широта",
"location_picker_latitude_error": "Вкажіть дійсну широту",
@@ -393,8 +393,8 @@
"multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено",
"multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено",
"my_albums": "Мої альбоми",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",
"networking_settings": "Мережа",
"networking_subtitle": "Керувати налаштуваннями кінцевої точки сервера",
"no_assets_to_show": "Елементи відсутні",
"no_name": "Без імені",
"notification_permission_dialog_cancel": "Скасувати",
@@ -403,7 +403,7 @@
"notification_permission_list_tile_content": "Надати дозвіл для сповіщень.",
"notification_permission_list_tile_enable_button": "Увімкнути Сповіщення",
"notification_permission_list_tile_title": "Дозвіл на Сповіщення",
"not_selected": "Not selected",
"not_selected": "Не вибрано",
"on_this_device": "На цьому пристрої",
"partner_list_user_photos": "Фотографії {user}",
"partner_list_view_all": "Переглянути усі",
@@ -417,7 +417,7 @@
"partner_page_stop_sharing_title": "Припинити надання ваших знімків?",
"partner_page_title": "Партнер",
"partners": "\nПартнери",
"paused": "Paused",
"paused": "Призупинено",
"people": "Люди",
"permission_onboarding_back": "Назад",
"permission_onboarding_continue_anyway": "Все одно продовжити",
@@ -430,7 +430,7 @@
"permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях",
"permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.",
"places": "Місця",
"preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_subtitle": "Керувати налаштуваннями додатка",
"preferences_settings_title": "Параметри",
"profile_drawer_app_logs": "Журнал",
"profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.",
@@ -445,7 +445,7 @@
"profile_drawer_trash": "Кошик",
"recently_added": "Нещодавно додані",
"recently_added_page_title": "Нещодавні",
"save": "Save",
"save": "Зберегти",
"save_to_gallery": "Зберегти в галерею",
"scaffold_body_error_occurred": "Виникла помилка",
"search_albums": "Пошук альбому",
@@ -491,7 +491,7 @@
"search_page_places": "Місця",
"search_page_recently_added": "Нещодавно додані",
"search_page_screenshots": "Знімки екрану",
"search_page_search_photos_videos": "Search for your photos and videos",
"search_page_search_photos_videos": "Шукайте ваші фотографії та відео",
"search_page_selfies": "Селфі",
"search_page_things": "Речі",
"search_page_videos": "Відео",
@@ -504,7 +504,7 @@
"select_additional_user_for_sharing_page_suggestions": "Пропозиції",
"select_user_for_sharing_page_err_album": "Не вдалося створити альбом",
"select_user_for_sharing_page_share_suggestions": "Пропозиції",
"server_endpoint": "Server Endpoint",
"server_endpoint": "Кінцева точка сервера",
"server_info_box_app_version": "Версія додатка",
"server_info_box_latest_release": "Остання версія",
"server_info_box_server_url": "URL сервера",
@@ -516,7 +516,7 @@
"setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду",
"setting_image_viewer_title": "Зображення",
"setting_languages_apply": "Застосувати",
"setting_languages_subtitle": "Change the app's language",
"setting_languages_subtitle": "Змінити мову додатка",
"setting_languages_title": "Мова",
"setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}",
"setting_notifications_notify_hours": "{} годин",
@@ -534,8 +534,8 @@
"settings_require_restart": "Перезавантажте програму для застосування цього налаштування",
"setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео",
"setting_video_viewer_looping_title": "Циклічне відтворення",
"setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.",
"setting_video_viewer_original_video_title": "Force original video",
"setting_video_viewer_original_video_subtitle": "При трансляції відео з сервера відтворювати оригінал, навіть якщо доступне транскодоване відео. Це може призвести до буферизації. Відео, доступні локально, відтворюються в оригінальній якості, незалежно від цього налаштування.",
"setting_video_viewer_original_video_title": "Примусово відтворювати оригінальне відео",
"setting_video_viewer_title": "Відео",
"share_add": "Додати",
"share_add_photos": "Додати знімки",
@@ -554,7 +554,7 @@
"shared_album_section_people_owner_label": "Власник",
"shared_album_section_people_title": "ЛЮДИ",
"share_dialog_preparing": "Підготовка...",
"shared_intent_upload_button_progress_text": "{} / {} Uploaded",
"shared_intent_upload_button_progress_text": "{} / {} Завантажено",
"shared_link_app_bar_title": "Спільні посилання",
"shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну",
"shared_link_clipboard_text": "Посилання: {}\nПароль: {}",
@@ -649,15 +649,15 @@
"trash_page_select_assets_btn": "Вибрані елементи",
"trash_page_select_btn": "Вибрати",
"trash_page_title": "Кошик ({})",
"upload": "Upload",
"upload": "Завантажити",
"upload_dialog_cancel": "Скасувати",
"upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?",
"upload_dialog_ok": "Завантажити",
"upload_dialog_title": "Завантажити Елементи",
"uploading": "Uploading",
"upload_to_immich": "Upload to Immich ({})",
"use_current_connection": "use current connection",
"validate_endpoint_error": "Please enter a valid URL",
"uploading": "Завантажується",
"upload_to_immich": "Завантажити в Immich ({})",
"use_current_connection": "використовувати поточне з'єднання",
"validate_endpoint_error": "Будь ласка, введіть дійсну URL-адресу.",
"version_announcement_overlay_ack": "Прийняти",
"version_announcement_overlay_release_notes": "примітки до випуску",
"version_announcement_overlay_text_1": "Вітаємо, є новий випуск ",
@@ -668,6 +668,6 @@
"viewer_remove_from_stack": "Видалити зі стеку",
"viewer_stack_use_as_main_asset": "Використовувати як основний елементи",
"viewer_unstack": "Розібрати стек",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
"wifi_name": "Назва Wi-Fi",
"your_wifi_name": "Ваша Wi-Fi мережа"
}

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.125.1</string>
<string>1.125.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>189</string>
<string>190</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.125.3"
version_number: "1.125.6"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget {
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
locale: context.locale,
);
if (date == null) {

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.125.3
- API version: 1.125.6
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -67,7 +67,7 @@ class AssetBulkUpdateDto {
///
num? longitude;
/// Minimum value: 0
/// Minimum value: -1
/// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file

View File

@@ -73,7 +73,7 @@ class UpdateAssetDto {
///
num? longitude;
/// Minimum value: 0
/// Minimum value: -1
/// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.125.3+179
version: 1.125.6+182
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -7454,7 +7454,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.125.3",
"version": "1.125.6",
"contact": {}
},
"tags": [],
@@ -7951,7 +7951,7 @@
},
"rating": {
"maximum": 5,
"minimum": 0,
"minimum": -1,
"type": "number"
}
},
@@ -12780,7 +12780,7 @@
},
"rating": {
"maximum": 5,
"minimum": 0,
"minimum": -1,
"type": "number"
}
},

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.125.3
* 1.125.6
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"description": "",
"author": "",
"private": true,

28
server/src/db.d.ts vendored
View File

@@ -3,21 +3,16 @@
* Please do not edit it manually.
*/
import type { ColumnType } from "kysely";
import type { ColumnType } from 'kysely';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[]
? U[]
: ArrayTypeImpl<T>;
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S[], I[], U[]>
: T[];
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
export type AssetsStatusEnum = "active" | "deleted" | "trashed";
export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
@@ -33,7 +28,7 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = "exif" | "machine-learning";
export type Sourcetype = 'exif' | 'machine-learning';
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -154,6 +149,12 @@ export interface AssetStack {
primaryAssetId: string;
}
export interface AssetUser {
assetId: string;
createdAt: Timestamp;
userId: string;
}
export interface Audit {
action: string;
createdAt: Generated<Timestamp>;
@@ -413,6 +414,7 @@ export interface DB {
asset_files: AssetFiles;
asset_job_status: AssetJobStatus;
asset_stack: AssetStack;
asset_user: AssetUser;
assets: Assets;
audit: Audit;
exif: Exif;
@@ -438,6 +440,6 @@ export interface DB {
tags_closure: TagsClosure;
user_metadata: UserMetadata;
users: Users;
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
version_history: VersionHistory;
}

View File

@@ -4,8 +4,8 @@ import { albumStub } from 'test/fixtures/album.stub';
describe('mapAlbum', () => {
it('should set start and end dates', () => {
const dto = mapAlbum(albumStub.twoAssets, false);
expect(dto.startDate).toEqual(new Date('2023-02-22T05:06:29.716Z'));
expect(dto.endDate).toEqual(new Date('2023-02-23T05:06:29.716Z'));
expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
});
it('should not set start and end dates for empty assets', () => {

View File

@@ -7,7 +7,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { getAssetDateTime } from 'src/utils/date-time';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
export class AlbumInfoDto {
@@ -165,8 +164,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const hasSharedLink = entity.sharedLinks?.length > 0;
const hasSharedUser = sharedUsers.length > 0;
let startDate = getAssetDateTime(assets.at(0));
let endDate = getAssetDateTime(assets.at(-1));
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
// Swap dates if start date is greater than end date.
if (startDate && endDate && startDate > endDate) {
[startDate, endDate] = [endDate, startDate];

View File

@@ -52,7 +52,7 @@ export class UpdateAssetBase {
@Optional()
@IsInt()
@Max(5)
@Min(0)
@Min(-1)
rating?: number;
}

View File

@@ -0,0 +1,22 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { Column, Entity, Index, ManyToOne, PrimaryColumn } from 'typeorm';
@Entity('asset_user')
@Index('IDX_assetId_userId', ['assetId', 'userId'])
export class AssetUserEntity {
@PrimaryColumn()
assetId!: string;
@ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
asset!: AssetEntity;
@PrimaryColumn()
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
user!: UserEntity;
@Column()
createdAt!: Date;
}

View File

@@ -193,7 +193,7 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select(sql<number[]>`smart_search.embedding`.as('embedding'));
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
}
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {

View File

@@ -5,6 +5,7 @@ import { APIKeyEntity } from 'src/entities/api-key.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetUserEntity } from 'src/entities/asset-user.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { AuditEntity } from 'src/entities/audit.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@@ -34,6 +35,7 @@ export const entities = [
AssetEntity,
AssetFaceEntity,
AssetFileEntity,
AssetUserEntity,
AssetJobStatusEntity,
AuditEntity,
ExifEntity,

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAssetUserTable1738099775096 implements MigrationInterface {
name = 'AddAssetUserTable1738099775096'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "asset_user" ("assetId" uuid NOT NULL, "userId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_f3d7f17ab93d60e007282726058" PRIMARY KEY ("assetId", "userId"))`);
await queryRunner.query(`CREATE INDEX "IDX_assetId_userId" ON "asset_user" ("assetId", "userId") `);
await queryRunner.query(`ALTER TABLE "asset_user" ADD CONSTRAINT "FK_07c8478e0936e78b553aaeceedb" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "asset_user" ADD CONSTRAINT "FK_85e2ef24493bdf649dfdfb769a2" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_user" DROP CONSTRAINT "FK_85e2ef24493bdf649dfdfb769a2"`);
await queryRunner.query(`ALTER TABLE "asset_user" DROP CONSTRAINT "FK_07c8478e0936e78b553aaeceedb"`);
await queryRunner.query(`DROP INDEX "public"."IDX_assetId_userId"`);
await queryRunner.query(`DROP TABLE "asset_user"`);
}
}

View File

@@ -202,13 +202,13 @@ order by
-- AlbumRepository.getMetadataForIds
select
"albums"."id" as "albumId",
min("assets"."fileCreatedAt") as "startDate",
max("assets"."fileCreatedAt") as "endDate",
min("assets"."localDateTime") as "startDate",
max("assets"."localDateTime") as "endDate",
count("assets"."id")::int as "assetCount"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "assets" on "assets"."id" = "album_assets"."assetsId"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
inner join "assets" on "assets"."id" = "album_assets"."assetsId"
where
"albums"."id" in ($1)
and "assets"."deletedAt" is null

View File

@@ -124,11 +124,11 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('assets', 'assets.id', 'album_assets.assetsId')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id as albumId')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.select((eb) => eb.fn.min('assets.localDateTime').as('startDate'))
.select((eb) => eb.fn.max('assets.localDateTime').as('endDate'))
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
.where('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null)

View File

@@ -81,7 +81,24 @@ export class AssetRepository implements IAssetRepository {
}
create(asset: Insertable<Assets>): Promise<AssetEntity> {
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise<AssetEntity>;
return this.db.transaction().execute(async (tx) => {
const newAsset = (await tx
.insertInto('assets')
.values(asset)
.returningAll()
.executeTakeFirst()) as any as AssetEntity;
await tx
.insertInto('asset_user')
.values({
assetId: newAsset.id,
userId: newAsset.ownerId,
createdAt: new Date(),
})
.execute();
return newAsset;
});
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
@@ -495,7 +512,6 @@ export class AssetRepository implements IAssetRepository {
.$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.select(withFiles)
.where('assets.isVisible', '=', true)
.where((eb) =>
eb.or([

View File

@@ -100,7 +100,6 @@ export class PersonRepository implements IPersonRepository {
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<AssetFaceEntity>;
}
@@ -109,7 +108,7 @@ export class PersonRepository implements IPersonRepository {
.selectFrom('person')
.selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
.$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))

View File

@@ -64,9 +64,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate ?? undefined,
endDate: albumMetadata[album.id].endDate ?? undefined,
assetCount: albumMetadata[album.id].assetCount,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}),
@@ -83,9 +83,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate ?? undefined,
endDate: albumMetadataForIds.endDate ?? undefined,
assetCount: albumMetadataForIds.assetCount,
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}

View File

@@ -194,7 +194,7 @@ export class MediaService extends BaseService {
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
}
if (asset.thumbhash != generated.thumbhash) {
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
}

View File

@@ -335,8 +335,8 @@ describe(MetadataService.name, () => {
expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
fileCreatedAt: assetStub.image.createdAt,
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: assetStub.image.fileCreatedAt,
localDateTime: assetStub.image.fileCreatedAt,
});
});
@@ -1162,6 +1162,17 @@ describe(MetadataService.name, () => {
}),
);
});
it('should handle valid negative rating value', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: -1,
}),
);
});
});
describe('handleQueueSidecar', () => {

View File

@@ -204,7 +204,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: validateRange(exifTags.Rating, 0, 5),
rating: validateRange(exifTags.Rating, -1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,

View File

@@ -1,5 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export const getAssetDateTime = (asset: AssetEntity | undefined) => {
return asset?.exifInfo?.dateTimeOriginal || asset?.fileCreatedAt;
};

View File

@@ -210,7 +210,7 @@ export const assetStub = {
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'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
isArchived: false,
duration: null,
@@ -574,7 +574,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-22T05:06:29.716Z'),
updatedAt: new Date('2023-02-22T05:06:29.716Z'),
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
localDateTime: new Date('2020-12-31T23:59:00.000Z'),
isFavorite: false,
isArchived: false,
isExternal: false,

View File

@@ -311,7 +311,7 @@ export const sharedLinkResponseStub = {
allowUpload: false,
allowDownload: false,
showMetadata: false,
album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt },
album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime },
assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
}),
};

View File

@@ -4,7 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(false),
probe: vitest.fn(),

27
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -16,6 +16,8 @@
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
@@ -75,7 +77,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@@ -1669,6 +1671,25 @@
"@photo-sphere-viewer/video-plugin": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/resolution-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/resolution-plugin/-/resolution-plugin-5.11.5.tgz",
"integrity": "sha512-Dbvp5bBtozD3IWt1Q0wORVaZBcB1bV9xUeoOS9A7F7b3EkQ2pkC5/jot/1AyM4wtU5wJ63NWHskQ1d7m6WWazQ==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.11.5",
"@photo-sphere-viewer/settings-plugin": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/settings-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/settings-plugin/-/settings-plugin-5.11.5.tgz",
"integrity": "sha512-ZgYaWjiBMhsoRH5ddW3h+v4J4LPmofsT7BBRq5UCssWw2Fsrvv7mFFRi4UbZ1qzeKmvNUOr8BaFQgX1ZLvUWfQ==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.11.5"
}
},
"node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -72,6 +72,8 @@
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",

View File

@@ -11,7 +11,10 @@
let { album }: Props = $props();
const formatDate = (date?: string) => {
return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined;
const dateWithoutTimeZone = date?.slice(0, -1);
return dateWithoutTimeZone
? new Date(dateWithoutTimeZone).toLocaleDateString($locale, dateFormats.album)
: undefined;
};
const getDateRange = (start?: string, end?: string) => {

View File

@@ -24,7 +24,7 @@
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
panorama={data}
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
originalPanorama={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
/>
{:catch}
{$t('errors.failed_to_load_asset')}

View File

@@ -7,18 +7,21 @@
type AdapterConstructor,
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import '@photo-sphere-viewer/core/index.css';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte';
interface Props {
panorama: string | { source: string };
originalImageUrl?: string;
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
}
let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
@@ -30,9 +33,33 @@
viewer = new Viewer({
adapter,
plugins,
plugins: [
SettingsPlugin,
[
ResolutionPlugin,
{
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
resolutions: [
{
id: 'default',
label: 'Default',
panorama,
},
...(originalPanorama
? [
{
id: 'original',
label: 'Original',
panorama: originalPanorama,
},
]
: []),
],
},
],
...plugins,
],
container,
panorama,
touchmoveTwoFingers: false,
mousewheelCtrlKey: false,
navbar,
@@ -40,15 +67,14 @@
maxFov: 120,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
if (originalImageUrl && !$alwaysLoadOriginalFile) {
if (originalPanorama && !$alwaysLoadOriginalFile) {
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
// Replace the preview with the original
viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => {
viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {});
});
void resolutionPlugin.setResolution('original');
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
}
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils';
import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
@@ -22,7 +22,13 @@
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer panorama={{ source: getAssetOriginalUrl(assetId) }} plugins={[videoPlugin]} {adapter} navbar />
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]}
{adapter}
navbar
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}

View File

@@ -15,7 +15,7 @@
</script>
<ul class="list-none ml-2">
{#each Object.entries(items) as [path, tree]}
{#each Object.entries(items).sort() as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)}
{#key key}

View File

@@ -24,7 +24,6 @@ class FoldersStore {
const uniquePaths = await getUniqueOriginalPaths();
this.uniquePaths.push(...uniquePaths);
this.uniquePaths.sort();
}
bustAssetCache() {

View File

@@ -157,7 +157,6 @@ async function fileUploader(
}
} catch (error) {
console.error(`Error calculating sha1 file=${assetFile.name})`, error);
throw error;
}
}

View File

@@ -44,7 +44,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction();

View File

@@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => {
return {
asset,
path,
currentFolders: Object.keys(tree || {}),
currentFolders: Object.keys(tree || {}).sort(),
pathAssets,
meta: {
title: $t('folders'),

View File

@@ -92,6 +92,7 @@
let personMerge1: PersonResponseDto | undefined = $state();
let personMerge2: PersonResponseDto | undefined = $state();
let potentialMergePeople: PersonResponseDto[] = $state([]);
let isSuggestionSelectedByUser = $state(false);
let personName = '';
let suggestedPeople: PersonResponseDto[] = $state([]);
@@ -233,15 +234,22 @@
personName = person.name;
personMerge1 = person;
personMerge2 = person2;
isSuggestionSelectedByUser = true;
viewMode = PersonPageViewMode.SUGGEST_MERGE;
};
const changeName = async () => {
viewMode = PersonPageViewMode.VIEW_ASSETS;
person.name = personName;
try {
isEditingName = false;
isEditingName = false;
if (isSuggestionSelectedByUser) {
// User canceled the merge
isSuggestionSelectedByUser = false;
return;
}
try {
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
notificationController.show({