Compare commits

..

31 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
github-actions 72a55c13b6 chore: version v1.125.3 2025-01-26 14:14:48 +00:00
sudbrack 206412267a fix(server): /search/random API returns same assets every call (#15682)
* Fix for server searchRandom function not returning random results

* Fix lint
2025-01-26 14:06:18 +00:00
Damiano Ferrari f780a56e24 fix(mobile): Misaligned text icon in circle avatar (#15683)
style(mobile): Use `DefaultTextStyle` for the text icon in `CircleAvatar`
2025-01-26 07:51:46 -06:00
Alex 7bbffccf76 fix(web): neon overflow on mobile screen (#15676) 2025-01-26 08:06:26 -05:00
Mert 05a446c259 fix(server): avoid duplicate rows in album queries (#15670)
* avoid duplicate rows

* left join, handle null vs. undefined

* update sql
2025-01-25 22:37:19 -06:00
Carsten Otto 4f725b95e1 fix(server): do not count deleted assets for album summary (#15668)
fixes #15645
fixes #15646
2025-01-25 16:45:13 -06:00
Carsten Otto 64b92cb24c fix(server): do not reset fileCreatedDate (#15650)
When marking an offline asset as online again, do not reset the
fileCreatedAt value. This value contains the "true" date, copied
from exif.dateTimeOriginal. If we overwrite this value, we'd need
to run the metadata extraction job again. Instead, we just leave
the old (and correct) value in place.

fixes #15640
2025-01-25 13:50:37 -06:00
Gagan Yadav 19f2f888ee fix(mobile): improve timezone picker (#15615)
- Fix missing timezones

- Remove the UTC prefix from timezone display text to align with web app

- Remove unnecessary layout builder

- Created a custom `DropdownSearchMenu` widget

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-01-25 13:36:49 -06:00
Alex d12b1c907d fix(server): bulk update location (#15642) 2025-01-25 11:58:07 -06:00
Robert Schütz 947c053c15 chore(server): add DB_URL supports Unix sockets unit test (#15629)
* test(server): DB_URL supports Unix sockets

* chore: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-01-25 10:38:00 +00:00
Regenxyz 79592701dd chore: fix typos in Thai Language Readme (#15637)
Update README_th_TH.md

Fixing weird Thai Translate
2025-01-25 10:30:53 +00:00
jdicioccio 39697cd973 fix: increase upload timeout (#15588)
Fix upload timeout issue

Fix an issue where when uploading a large file, the upload would consistently abort after 30 minutes. I changed this timeout from 30 minutes to 1 day. Maybe that's excessive, or maybe the timeout isn't even needed, but the current 30 minute timeout definitely seems way too short.
2025-01-25 04:26:52 -06:00
Jonathan Jogenfors 10e518db42 chore(server): print stack in case of worker error (#15632)
feat: show error stack
2025-01-24 22:45:55 -05:00
Mert 72fa31f9e9 fix(server): changing vector dim size (#15630) 2025-01-24 20:01:24 -05:00
74 changed files with 870 additions and 429 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.43", "version": "2.2.47",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.43", "version": "2.2.47",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.43", "version": "2.2.47",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -99,6 +99,11 @@ const projects: CommunityProjectProps[] = [
description: 'Downloads a configurable number of random photos based on people or album ID.', description: 'Downloads a configurable number of random photos based on people or album ID.',
url: 'https://github.com/jon6fingrs/immich-dl', 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 { function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
+16
View File
@@ -1,4 +1,20 @@
[ [
{
"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"
},
{ {
"label": "v1.125.2", "label": "v1.125.2",
"url": "https://v1.125.2.archive.immich.app" "url": "https://v1.125.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.2", "version": "1.125.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.43", "version": "2.2.47",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.2", "version": "1.125.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+101 -45
View File
@@ -22,79 +22,92 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser'; const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink'; const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared'; const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => { describe('/albums', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto; let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto; let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[]; let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto; let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[]; let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); 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.user1),
utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3), 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, { isFavorite: true }),
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]); ]);
user1Albums = await Promise.all([ [user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
utils.createAlbum(user1.accessToken, { Promise.all([
albumName: user1SharedEditorUser, utils.createAlbum(user1.accessToken, {
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }], albumName: user1SharedEditorUser,
assetIds: [user1Asset1.id], albumUsers: [
}), { userId: admin.userId, role: AlbumUserRole.Editor },
utils.createAlbum(user1.accessToken, { { userId: user2.userId, role: AlbumUserRole.Editor },
albumName: user1SharedLink, ],
assetIds: [user1Asset1.id], assetIds: [user1Asset1.id],
}), }),
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1NotShared, albumName: user1SharedLink,
assetIds: [user1Asset1.id, user1Asset2.id], assetIds: [user1Asset1.id],
}), }),
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser, albumName: user1NotShared,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }], assetIds: [user1Asset1.id, user1Asset2.id],
assetIds: [user1Asset1.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([ 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 // add shared link to user1SharedLink album
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Album,
@@ -107,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', () => { describe('GET /albums', () => {
@@ -284,6 +301,25 @@ describe('/albums', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(5); 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', () => { describe('GET /albums/:id', () => {
@@ -362,6 +398,26 @@ describe('/albums', () => {
shared: true, shared: true,
}); });
}); });
it('should not count trashed assets', async () => {
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
});
}); });
describe('GET /albums/statistics', () => { describe('GET /albums/statistics', () => {
+14
View File
@@ -701,6 +701,20 @@ describe('/asset', () => {
expect(status).toEqual(200); 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 () => { it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(app) const { status, body } = await request(app)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.125.2" version = "1.125.6"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 178, "android.injected.version.code" => 182,
"android.injected.version.name" => "1.125.2", "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') 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')
+1
View File
@@ -248,6 +248,7 @@
"download_waiting_to_retry": "Waiting to retry", "download_waiting_to_retry": "Waiting to retry",
"edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone", "edit_date_time_dialog_timezone": "Timezone",
"edit_date_time_dialog_search_timezone": "Search timezone...",
"edit_image_title": "Edit", "edit_image_title": "Edit",
"edit_location_dialog_title": "Location", "edit_location_dialog_title": "Location",
"end_date": "End date", "end_date": "End date",
+49 -49
View File
@@ -7,10 +7,10 @@
"action_common_select": "Вибрати", "action_common_select": "Вибрати",
"action_common_update": "Оновити", "action_common_update": "Оновити",
"add_a_name": "Додати ім'я", "add_a_name": "Додати ім'я",
"add_endpoint": "Add endpoint", "add_endpoint": "Додати кінцеву точку",
"add_to_album_bottom_sheet_added": "Додано до {album}", "add_to_album_bottom_sheet_added": "Додано до {album}",
"add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}",
"advanced_settings_log_level_title": "Log level: {}", "advanced_settings_log_level_title": "Рівень журналу: {}",
"advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.", "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.",
"advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням",
"advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.", "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.",
@@ -66,12 +66,12 @@
"assets_restored_successfully": "{} елемент(и) успішно відновлено", "assets_restored_successfully": "{} елемент(и) успішно відновлено",
"assets_trashed": "{} елемент(и) поміщено до кошика", "assets_trashed": "{} елемент(и) поміщено до кошика",
"assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich", "assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich",
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_subtitle": "Керувати налаштуваннями переглядача галереї",
"asset_viewer_settings_title": "Переглядач зображень", "asset_viewer_settings_title": "Переглядач зображень",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_subtitle": "Підключатися локально через визначену Wi-Fi мережу, коли вона доступна, і використовувати альтернативні з'єднання в інших випадках",
"automatic_endpoint_switching_title": "Automatic URL switching", "automatic_endpoint_switching_title": "Автоперемикання URL-адрес",
"background_location_permission": "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", "background_location_permission_content": "Щоб перемикатися між мережами під час роботи у фоновому режимі, Immich *завжди* повинен мати доступ до точного місцезнаходження, щоб додаток міг зчитувати назву Wi-Fi мережі",
"backup_album_selection_page_albums_device": "Альбоми на пристрої ({})", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({})",
"backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити", "backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити",
"backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.", "backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.",
@@ -137,7 +137,7 @@
"backup_manual_success": "Успіх", "backup_manual_success": "Успіх",
"backup_manual_title": "Стан завантаження", "backup_manual_title": "Стан завантаження",
"backup_options_page_title": "Резервне копіювання", "backup_options_page_title": "Резервне копіювання",
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Керування завантаженням у фоновому та передньому режимах",
"cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)", "cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)",
"cache_settings_clear_cache_button": "Очистити кеш", "cache_settings_clear_cache_button": "Очистити кеш",
"cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.",
@@ -156,17 +156,17 @@
"cache_settings_tile_subtitle": "Керування поведінкою локального сховища", "cache_settings_tile_subtitle": "Керування поведінкою локального сховища",
"cache_settings_tile_title": "Локальне сховище", "cache_settings_tile_title": "Локальне сховище",
"cache_settings_title": "Налаштування кешування", "cache_settings_title": "Налаштування кешування",
"cancel": "Cancel", "cancel": "Скасувати",
"canceled": "Canceled", "canceled": "Скасовано",
"change_display_order": "Change display order", "change_display_order": "Змінити порядок відображення",
"change_password_form_confirm_password": "Підтвердити пароль", "change_password_form_confirm_password": "Підтвердити пароль",
"change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.",
"change_password_form_new_password": "Новий пароль", "change_password_form_new_password": "Новий пароль",
"change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_password_mismatch": "Паролі не співпадають",
"change_password_form_reenter_new_password": "Повторіть новий пароль", "change_password_form_reenter_new_password": "Повторіть новий пароль",
"check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup": "Перевірити пошкоджені резервні копії",
"check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_button": "Виконати перевірку",
"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_description": "Виконуйте цю перевірку лише через Wi-Fi та після того, як усі активи будуть заархівовані. Процес може зайняти кілька хвилин.",
"client_cert_dialog_msg_confirm": "OK", "client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Введіть пароль", "client_cert_enter_password": "Введіть пароль",
"client_cert_import": "Імпорт", "client_cert_import": "Імпорт",
@@ -181,7 +181,7 @@
"common_create_new_album": "Створити новий альбом", "common_create_new_album": "Створити новий альбом",
"common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.", "common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.",
"common_shared": "Спільні", "common_shared": "Спільні",
"completed": "Completed", "completed": "Завершено",
"contextual_search": "Схід сонця на пляжі", "contextual_search": "Схід сонця на пляжі",
"control_bottom_app_bar_add_to_album": "Додати у альбом", "control_bottom_app_bar_add_to_album": "Додати у альбом",
"control_bottom_app_bar_album_info": "{} елементи", "control_bottom_app_bar_album_info": "{} елементи",
@@ -199,7 +199,7 @@
"control_bottom_app_bar_share": "Поділитися", "control_bottom_app_bar_share": "Поділитися",
"control_bottom_app_bar_share_to": "Поділитися", "control_bottom_app_bar_share_to": "Поділитися",
"control_bottom_app_bar_stack": "Стек", "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_unarchive": "Розархівувати",
"control_bottom_app_bar_unfavorite": "Видалити з улюблених", "control_bottom_app_bar_unfavorite": "Видалити з улюблених",
"control_bottom_app_bar_upload": "Завантажити", "control_bottom_app_bar_upload": "Завантажити",
@@ -213,7 +213,7 @@
"crop": "Кадрувати", "crop": "Кадрувати",
"curated_location_page_title": "Місця", "curated_location_page_title": "Місця",
"curated_object_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": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy", "daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a", "date_format": "E, LLL d, y • h:mm a",
@@ -250,10 +250,10 @@
"edit_date_time_dialog_timezone": "Часовий пояс", "edit_date_time_dialog_timezone": "Часовий пояс",
"edit_image_title": "Редагувати", "edit_image_title": "Редагувати",
"edit_location_dialog_title": "Місцезнаходження", "edit_location_dialog_title": "Місцезнаходження",
"end_date": "End date", "end_date": "Дата завершення",
"enqueued": "Enqueued", "enqueued": "В черзі",
"enter_wifi_name": "Enter WiFi name", "enter_wifi_name": "Введіть назву Wi-Fi",
"error_change_sort_album": "Failed to change album sort order", "error_change_sort_album": "Не вдалося змінити порядок сортування альбому",
"error_saving_image": "Помилка: {}", "error_saving_image": "Помилка: {}",
"exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_description": "Додати опис...",
"exif_bottom_sheet_details": "ПОДРОБИЦІ", "exif_bottom_sheet_details": "ПОДРОБИЦІ",
@@ -265,16 +265,16 @@
"experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_new_asset_list_title": "Експериментальний макет знімків",
"experimental_settings_subtitle": "На власний ризик!", "experimental_settings_subtitle": "На власний ризик!",
"experimental_settings_title": "Експериментальні", "experimental_settings_title": "Експериментальні",
"external_network": "External network", "external_network": "Зовнішня мережа",
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "external_network_sheet_info": "Якщо ви не підключені до бажаної Wi-Fi мережі, додаток підключиться до сервера через першу доступну URL-адресу зверху вниз",
"failed": "Failed", "failed": "Не вдалося",
"favorites": "Вибране", "favorites": "Вибране",
"favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_no_favorites": "Немає улюблених елементів",
"favorites_page_title": "Улюблені", "favorites_page_title": "Улюблені",
"filename_search": "Ім'я або розширення файлу", "filename_search": "Ім'я або розширення файлу",
"filter": "Фільтр", "filter": "Фільтр",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "get_wifiname_error": "Не вдалося отримати назву Wi-Fi мережі. Переконайтеся, що ви надали необхідні дозволи та підключені до Wi-Fi мережі",
"grant_permission": "Grant permission", "grant_permission": "Надати дозвіл",
"haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_switch": "Увімкнути тактильну віддачу",
"haptic_feedback_title": "Тактильна віддача", "haptic_feedback_title": "Тактильна віддача",
"header_settings_add_header_tip": "Додати заголовок", "header_settings_add_header_tip": "Додати заголовок",
@@ -320,10 +320,10 @@
"library_page_sort_most_oldest_photo": "Найдавніші фото", "library_page_sort_most_oldest_photo": "Найдавніші фото",
"library_page_sort_most_recent_photo": "Найновіші фото", "library_page_sort_most_recent_photo": "Найновіші фото",
"library_page_sort_title": "Назва альбому", "library_page_sort_title": "Назва альбому",
"local_network": "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", "local_network_sheet_info": "Додаток підключатиметься до сервера через цю URL-адресу при використанні вказаної Wi-Fi мережі",
"location_permission": "Location permission", "location_permission": "Дозвіл до місцезнаходження",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_permission_content": "Для використання функції автоперемикання Immich потрібен дозвіл на точне місцезнаходження, щоб зчитувати назву поточної Wi-Fi мережі",
"location_picker_choose_on_map": "Обрати на мапі", "location_picker_choose_on_map": "Обрати на мапі",
"location_picker_latitude": "Широта", "location_picker_latitude": "Широта",
"location_picker_latitude_error": "Вкажіть дійсну широту", "location_picker_latitude_error": "Вкажіть дійсну широту",
@@ -393,8 +393,8 @@
"multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено",
"multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено",
"my_albums": "Мої альбоми", "my_albums": "Мої альбоми",
"networking_settings": "Networking", "networking_settings": "Мережа",
"networking_subtitle": "Manage the server endpoint settings", "networking_subtitle": "Керувати налаштуваннями кінцевої точки сервера",
"no_assets_to_show": "Елементи відсутні", "no_assets_to_show": "Елементи відсутні",
"no_name": "Без імені", "no_name": "Без імені",
"notification_permission_dialog_cancel": "Скасувати", "notification_permission_dialog_cancel": "Скасувати",
@@ -403,7 +403,7 @@
"notification_permission_list_tile_content": "Надати дозвіл для сповіщень.", "notification_permission_list_tile_content": "Надати дозвіл для сповіщень.",
"notification_permission_list_tile_enable_button": "Увімкнути Сповіщення", "notification_permission_list_tile_enable_button": "Увімкнути Сповіщення",
"notification_permission_list_tile_title": "Дозвіл на Сповіщення", "notification_permission_list_tile_title": "Дозвіл на Сповіщення",
"not_selected": "Not selected", "not_selected": "Не вибрано",
"on_this_device": "На цьому пристрої", "on_this_device": "На цьому пристрої",
"partner_list_user_photos": "Фотографії {user}", "partner_list_user_photos": "Фотографії {user}",
"partner_list_view_all": "Переглянути усі", "partner_list_view_all": "Переглянути усі",
@@ -417,7 +417,7 @@
"partner_page_stop_sharing_title": "Припинити надання ваших знімків?", "partner_page_stop_sharing_title": "Припинити надання ваших знімків?",
"partner_page_title": "Партнер", "partner_page_title": "Партнер",
"partners": "\nПартнери", "partners": "\nПартнери",
"paused": "Paused", "paused": "Призупинено",
"people": "Люди", "people": "Люди",
"permission_onboarding_back": "Назад", "permission_onboarding_back": "Назад",
"permission_onboarding_continue_anyway": "Все одно продовжити", "permission_onboarding_continue_anyway": "Все одно продовжити",
@@ -430,7 +430,7 @@
"permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях",
"permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.",
"places": "Місця", "places": "Місця",
"preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_subtitle": "Керувати налаштуваннями додатка",
"preferences_settings_title": "Параметри", "preferences_settings_title": "Параметри",
"profile_drawer_app_logs": "Журнал", "profile_drawer_app_logs": "Журнал",
"profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.",
@@ -445,7 +445,7 @@
"profile_drawer_trash": "Кошик", "profile_drawer_trash": "Кошик",
"recently_added": "Нещодавно додані", "recently_added": "Нещодавно додані",
"recently_added_page_title": "Нещодавні", "recently_added_page_title": "Нещодавні",
"save": "Save", "save": "Зберегти",
"save_to_gallery": "Зберегти в галерею", "save_to_gallery": "Зберегти в галерею",
"scaffold_body_error_occurred": "Виникла помилка", "scaffold_body_error_occurred": "Виникла помилка",
"search_albums": "Пошук альбому", "search_albums": "Пошук альбому",
@@ -491,7 +491,7 @@
"search_page_places": "Місця", "search_page_places": "Місця",
"search_page_recently_added": "Нещодавно додані", "search_page_recently_added": "Нещодавно додані",
"search_page_screenshots": "Знімки екрану", "search_page_screenshots": "Знімки екрану",
"search_page_search_photos_videos": "Search for your photos and videos", "search_page_search_photos_videos": "Шукайте ваші фотографії та відео",
"search_page_selfies": "Селфі", "search_page_selfies": "Селфі",
"search_page_things": "Речі", "search_page_things": "Речі",
"search_page_videos": "Відео", "search_page_videos": "Відео",
@@ -504,7 +504,7 @@
"select_additional_user_for_sharing_page_suggestions": "Пропозиції", "select_additional_user_for_sharing_page_suggestions": "Пропозиції",
"select_user_for_sharing_page_err_album": "Не вдалося створити альбом", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом",
"select_user_for_sharing_page_share_suggestions": "Пропозиції", "select_user_for_sharing_page_share_suggestions": "Пропозиції",
"server_endpoint": "Server Endpoint", "server_endpoint": "Кінцева точка сервера",
"server_info_box_app_version": "Версія додатка", "server_info_box_app_version": "Версія додатка",
"server_info_box_latest_release": "Остання версія", "server_info_box_latest_release": "Остання версія",
"server_info_box_server_url": "URL сервера", "server_info_box_server_url": "URL сервера",
@@ -516,7 +516,7 @@
"setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду",
"setting_image_viewer_title": "Зображення", "setting_image_viewer_title": "Зображення",
"setting_languages_apply": "Застосувати", "setting_languages_apply": "Застосувати",
"setting_languages_subtitle": "Change the app's language", "setting_languages_subtitle": "Змінити мову додатка",
"setting_languages_title": "Мова", "setting_languages_title": "Мова",
"setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}", "setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}",
"setting_notifications_notify_hours": "{} годин", "setting_notifications_notify_hours": "{} годин",
@@ -534,8 +534,8 @@
"settings_require_restart": "Перезавантажте програму для застосування цього налаштування", "settings_require_restart": "Перезавантажте програму для застосування цього налаштування",
"setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео", "setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео",
"setting_video_viewer_looping_title": "Циклічне відтворення", "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_subtitle": "При трансляції відео з сервера відтворювати оригінал, навіть якщо доступне транскодоване відео. Це може призвести до буферизації. Відео, доступні локально, відтворюються в оригінальній якості, незалежно від цього налаштування.",
"setting_video_viewer_original_video_title": "Force original video", "setting_video_viewer_original_video_title": "Примусово відтворювати оригінальне відео",
"setting_video_viewer_title": "Відео", "setting_video_viewer_title": "Відео",
"share_add": "Додати", "share_add": "Додати",
"share_add_photos": "Додати знімки", "share_add_photos": "Додати знімки",
@@ -554,7 +554,7 @@
"shared_album_section_people_owner_label": "Власник", "shared_album_section_people_owner_label": "Власник",
"shared_album_section_people_title": "ЛЮДИ", "shared_album_section_people_title": "ЛЮДИ",
"share_dialog_preparing": "Підготовка...", "share_dialog_preparing": "Підготовка...",
"shared_intent_upload_button_progress_text": "{} / {} Uploaded", "shared_intent_upload_button_progress_text": "{} / {} Завантажено",
"shared_link_app_bar_title": "Спільні посилання", "shared_link_app_bar_title": "Спільні посилання",
"shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну", "shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну",
"shared_link_clipboard_text": "Посилання: {}\nПароль: {}", "shared_link_clipboard_text": "Посилання: {}\nПароль: {}",
@@ -649,15 +649,15 @@
"trash_page_select_assets_btn": "Вибрані елементи", "trash_page_select_assets_btn": "Вибрані елементи",
"trash_page_select_btn": "Вибрати", "trash_page_select_btn": "Вибрати",
"trash_page_title": "Кошик ({})", "trash_page_title": "Кошик ({})",
"upload": "Upload", "upload": "Завантажити",
"upload_dialog_cancel": "Скасувати", "upload_dialog_cancel": "Скасувати",
"upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?", "upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?",
"upload_dialog_ok": "Завантажити", "upload_dialog_ok": "Завантажити",
"upload_dialog_title": "Завантажити Елементи", "upload_dialog_title": "Завантажити Елементи",
"uploading": "Uploading", "uploading": "Завантажується",
"upload_to_immich": "Upload to Immich ({})", "upload_to_immich": "Завантажити в Immich ({})",
"use_current_connection": "use current connection", "use_current_connection": "використовувати поточне з'єднання",
"validate_endpoint_error": "Please enter a valid URL", "validate_endpoint_error": "Будь ласка, введіть дійсну URL-адресу.",
"version_announcement_overlay_ack": "Прийняти", "version_announcement_overlay_ack": "Прийняти",
"version_announcement_overlay_release_notes": "примітки до випуску", "version_announcement_overlay_release_notes": "примітки до випуску",
"version_announcement_overlay_text_1": "Вітаємо, є новий випуск ", "version_announcement_overlay_text_1": "Вітаємо, є новий випуск ",
@@ -668,6 +668,6 @@
"viewer_remove_from_stack": "Видалити зі стеку", "viewer_remove_from_stack": "Видалити зі стеку",
"viewer_stack_use_as_main_asset": "Використовувати як основний елементи", "viewer_stack_use_as_main_asset": "Використовувати як основний елементи",
"viewer_unstack": "Розібрати стек", "viewer_unstack": "Розібрати стек",
"wifi_name": "WiFi Name", "wifi_name": "Назва Wi-Fi",
"your_wifi_name": "Your WiFi name" "your_wifi_name": "Ваша Wi-Fi мережа"
} }
+6 -6
View File
@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189; CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
+2 -2
View File
@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.125.1</string> <string>1.125.2</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>189</string> <string>190</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.125.2" version_number: "1.125.6"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
-1
View File
@@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget {
fieldEndHintText: 'end_date'.tr(), fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar, initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
locale: context.locale,
); );
if (date == null) { if (date == null) {
+69 -74
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/common/dropdown_search_menu.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/timezone.dart'; import 'package:timezone/timezone.dart';
@@ -24,7 +25,7 @@ Future<String?> showDateTimePicker({
} }
String _getFormattedOffset(int offsetInMilli, tz.Location location) { String _getFormattedOffset(int offsetInMilli, tz.Location location) {
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})"; return "${location.name} (${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
} }
class _DateTimePicker extends HookWidget { class _DateTimePicker extends HookWidget {
@@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget {
// returns a list of location<name> along with it's offset in duration // returns a list of location<name> along with it's offset in duration
List<_TimeZoneOffset> getAllTimeZones() { List<_TimeZoneOffset> getAllTimeZones() {
return tz.timeZoneDatabase.locations.values return tz.timeZoneDatabase.locations.values
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
.map(_TimeZoneOffset.fromLocation) .map(_TimeZoneOffset.fromLocation)
.sorted() .sorted()
.toList(); .toList();
@@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget {
context.pop(dtWithOffset); context.pop(dtWithOffset);
} }
return LayoutBuilder( return AlertDialog(
builder: (context, constraint) => AlertDialog( contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18),
contentPadding: actions: [
const EdgeInsets.symmetric(vertical: 32, horizontal: 18), TextButton(
actions: [ onPressed: () => context.pop(),
TextButton( child: Text(
onPressed: () => context.pop(), "action_common_cancel",
child: Text( style: context.textTheme.bodyMedium?.copyWith(
"action_common_cancel", fontWeight: FontWeight.w600,
style: context.textTheme.bodyMedium?.copyWith( color: context.colorScheme.error,
fontWeight: FontWeight.w600, ),
color: context.colorScheme.error, ).tr(),
),
TextButton(
onPressed: popWithDateTime,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"edit_date_time_dialog_date_time",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
).tr(),
const SizedBox(height: 32),
ListTile(
tileColor: context.colorScheme.surfaceContainerHighest,
shape: ShapeBorder.lerp(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
), ),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
1,
),
trailing: Icon(
Icons.edit_outlined,
size: 18,
color: context.primaryColor,
),
title: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyMedium,
).tr(), ).tr(),
onTap: pickDate,
), ),
TextButton( const SizedBox(height: 24),
onPressed: popWithDateTime, DropdownSearchMenu(
child: Text( trailingIcon: Icon(
"action_common_update", Icons.arrow_drop_down,
style: context.textTheme.bodyMedium?.copyWith( color: context.primaryColor,
fontWeight: FontWeight.w600, ),
color: context.primaryColor, hintText: "edit_date_time_dialog_timezone".tr(),
), label: const Text('edit_date_time_dialog_timezone').tr(),
).tr(), textStyle: context.textTheme.bodyMedium,
onSelected: (value) => tzOffset.value = value,
initialSelection: tzOffset.value,
dropdownMenuEntries: menuEntries,
), ),
], ],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"edit_date_time_dialog_date_time",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
).tr(),
const SizedBox(height: 32),
ListTile(
tileColor: context.colorScheme.surfaceContainerHighest,
shape: ShapeBorder.lerp(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
1,
),
trailing: Icon(
Icons.edit_outlined,
size: 18,
color: context.primaryColor,
),
title: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyMedium,
).tr(),
onTap: pickDate,
),
const SizedBox(height: 24),
DropdownMenu(
width: 275,
menuHeight: 300,
trailingIcon: Icon(
Icons.arrow_drop_down,
color: context.primaryColor,
),
hintText: "edit_date_time_dialog_timezone".tr(),
label: const Text('edit_date_time_dialog_timezone').tr(),
textStyle: context.textTheme.bodyMedium,
onSelected: (value) => tzOffset.value = value!,
initialSelection: tzOffset.value,
dropdownMenuEntries: menuEntries,
),
],
),
), ),
); );
} }
@@ -0,0 +1,169 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DropdownSearchMenu<T> extends HookWidget {
const DropdownSearchMenu({
super.key,
required this.dropdownMenuEntries,
this.initialSelection,
this.onSelected,
this.trailingIcon,
this.hintText,
this.label,
this.textStyle,
this.menuConstraints,
});
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
final T? initialSelection;
final ValueChanged<T>? onSelected;
final Widget? trailingIcon;
final String? hintText;
final Widget? label;
final TextStyle? textStyle;
final BoxConstraints? menuConstraints;
@override
Widget build(BuildContext context) {
final selectedItem = useState<DropdownMenuEntry<T>?>(
dropdownMenuEntries
.firstWhereOrNull((item) => item.value == initialSelection),
);
final showTimeZoneDropdown = useState<bool>(false);
final effectiveConstraints = menuConstraints ??
const BoxConstraints(
minWidth: 280,
maxWidth: 280,
minHeight: 0,
maxHeight: 280,
);
final inputDecoration = InputDecoration(
contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
border: const OutlineInputBorder(),
suffixIcon: trailingIcon,
label: label,
hintText: hintText,
).applyDefaults(context.themeData.inputDecorationTheme);
if (!showTimeZoneDropdown.value) {
return ConstrainedBox(
constraints: effectiveConstraints,
child: GestureDetector(
onTap: () => showTimeZoneDropdown.value = true,
child: InputDecorator(
decoration: inputDecoration,
child: selectedItem.value != null
? Text(
selectedItem.value!.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
)
: null,
),
),
);
}
return ConstrainedBox(
constraints: effectiveConstraints,
child: Autocomplete<DropdownMenuEntry<T>>(
displayStringForOption: (option) => option.label,
optionsBuilder: (textEditingValue) {
return dropdownMenuEntries.where(
(item) => item.label
.toLowerCase()
.trim()
.contains(textEditingValue.text.toLowerCase().trim()),
);
},
onSelected: (option) {
selectedItem.value = option;
showTimeZoneDropdown.value = false;
onSelected?.call(option.value);
},
fieldViewBuilder: (context, textEditingController, focusNode, _) {
return TextField(
autofocus: true,
focusNode: focusNode,
controller: textEditingController,
decoration: inputDecoration.copyWith(
hintText: "edit_date_time_dialog_search_timezone".tr(),
),
maxLines: 1,
style: context.textTheme.bodyMedium,
expands: false,
onTapOutside: (event) {
showTimeZoneDropdown.value = false;
focusNode.unfocus();
},
onSubmitted: (_) {
showTimeZoneDropdown.value = false;
},
);
},
optionsViewBuilder: (context, onSelected, options) {
// This widget is a copy of the default implementation.
// We have only changed the `constraints` parameter.
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: effectiveConstraints,
child: Material(
elevation: 4.0,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
child: Builder(
builder: (BuildContext context) {
final bool highlight =
AutocompleteHighlightedOption.of(context) ==
index;
if (highlight) {
SchedulerBinding.instance.addPostFrameCallback(
(Duration timeStamp) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
);
},
debugLabel: 'AutocompleteOptions.ensureVisible',
);
}
return Container(
color: highlight
? Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.12)
: null,
padding: const EdgeInsets.all(16.0),
child: Text(
option.label,
style: textStyle,
),
);
},
),
);
},
),
),
),
);
},
),
);
}
}
@@ -28,8 +28,7 @@ class UserCircleAvatar extends ConsumerWidget {
final profileImageUrl = final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
final textIcon = Text( final textIcon = DefaultTextStyle(
user.name[0].toUpperCase(),
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
@@ -37,6 +36,7 @@ class UserCircleAvatar extends ConsumerWidget {
? Colors.black ? Colors.black
: Colors.white, : Colors.white,
), ),
child: Text(user.name[0].toUpperCase()),
); );
return CircleAvatar( return CircleAvatar(
backgroundColor: user.avatarColor.toColor(), backgroundColor: user.avatarColor.toColor(),
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.125.2 - API version: 1.125.6
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -67,7 +67,7 @@ class AssetBulkUpdateDto {
/// ///
num? longitude; num? longitude;
/// Minimum value: 0 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
+1 -1
View File
@@ -73,7 +73,7 @@ class UpdateAssetDto {
/// ///
num? longitude; num? longitude;
/// Minimum value: 0 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.125.2+178 version: 1.125.6+182
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+3 -3
View File
@@ -7454,7 +7454,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.125.2", "version": "1.125.6",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@@ -7951,7 +7951,7 @@
}, },
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": 0, "minimum": -1,
"type": "number" "type": "number"
} }
}, },
@@ -12780,7 +12780,7 @@
}, },
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": 0, "minimum": -1,
"type": "number" "type": "number"
} }
}, },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.125.2 * 1.125.6
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+11 -11
View File
@@ -40,12 +40,12 @@
<a href="README_vi_VN.md">Tiếng Việt</a> <a href="README_vi_VN.md">Tiếng Việt</a>
</p> </p>
## ข้อจำกัดความรับผิดชอบ ## ข้อควรระวัง
- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก** - ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**มีการเปลี่ยนแปลงบ่อยมาก**
- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย - ⚠️ อาจจะเกิดข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย
- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** - ⚠️ **ห้ามใช้ระบบนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ**
- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ! - ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ
> [!NOTE] > [!NOTE]
> คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/ > คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/
@@ -79,15 +79,15 @@
| :----------------------------------------- | ------ | ------ | | :----------------------------------------- | ------ | ------ |
| อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ | | อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ |
| การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A | | การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A |
| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ | | ป้องกันการซ้ำของไฟล์ | ใช่ | ใช่ |
| เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A | | เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A |
| ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ | | ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ |
| รองรับผู้ใช้หลายคน | ใช่ | ใช่ | | รองรับผู้ใช้หลายคน | ใช่ | ใช่ |
| อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ | | อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ |
| แถบเลื่อนแบบลากได้ | ใช่ | ใช่ | | แถบเลื่อนแบบลากได้ | ใช่ | ใช่ |
| รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ | | รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ |
| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ | | ดูข้อมูลเมตาดาต้า (EXIF, แผนที่) | ใช่ | ใช่ |
| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | | ค้นหาจากข้อมูลเมตาดาต้า วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ |
| ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ | | ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ |
| การสำรองข้อมูลพื้นหลัง | ใช่ | N/A | | การสำรองข้อมูลพื้นหลัง | ใช่ | N/A |
| การเลื่อนแบบเสมือน | ใช่ | ใช่ | | การเลื่อนแบบเสมือน | ใช่ | ใช่ |
@@ -100,7 +100,7 @@
| การจัดเก็บและรายการโปรด | ใช่ | ใช่ | | การจัดเก็บและรายการโปรด | ใช่ | ใช่ |
| แผนที่ทั่วโลก | ใช่ | ใช่ | | แผนที่ทั่วโลก | ใช่ | ใช่ |
| การแชร์กับคู่หู | ใช่ | ใช่ | | การแชร์กับคู่หู | ใช่ | ใช่ |
| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | | ระบบจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ |
| ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ | | ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ |
| รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ | | รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ |
| แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ | | แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ |
@@ -108,13 +108,13 @@
## การแปลภาษา ## การแปลภาษา
อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations) อ่านเพิ่มเติมเกี่ยวกับการแปล [ที่นี่](https://immich.app/docs/developer/translations)
<a href="https://hosted.weblate.org/engage/immich/"> <a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="สถานะการแปล" /> <img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="สถานะการแปล" />
</a> </a>
## กิจกรรมของคลังเก็บข้อมูล ## กิจกรรมของ Repository
![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats") ![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.125.2", "version": "1.125.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.0", "@nestjs/bullmq": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.125.2", "version": "1.125.6",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+15 -13
View File
@@ -3,21 +3,16 @@
* Please do not edit it manually. * 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)[] export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
? U[]
: ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
? 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> export type Generated<T> =
? ColumnType<S, I | undefined, U> T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>; 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 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>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -154,6 +149,12 @@ export interface AssetStack {
primaryAssetId: string; primaryAssetId: string;
} }
export interface AssetUser {
assetId: string;
createdAt: Timestamp;
userId: string;
}
export interface Audit { export interface Audit {
action: string; action: string;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
@@ -413,6 +414,7 @@ export interface DB {
asset_files: AssetFiles; asset_files: AssetFiles;
asset_job_status: AssetJobStatus; asset_job_status: AssetJobStatus;
asset_stack: AssetStack; asset_stack: AssetStack;
asset_user: AssetUser;
assets: Assets; assets: Assets;
audit: Audit; audit: Audit;
exif: Exif; exif: Exif;
@@ -438,6 +440,6 @@ export interface DB {
tags_closure: TagsClosure; tags_closure: TagsClosure;
user_metadata: UserMetadata; user_metadata: UserMetadata;
users: Users; users: Users;
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
version_history: VersionHistory; version_history: VersionHistory;
} }
+2 -2
View File
@@ -4,8 +4,8 @@ import { albumStub } from 'test/fixtures/album.stub';
describe('mapAlbum', () => { describe('mapAlbum', () => {
it('should set start and end dates', () => { it('should set start and end dates', () => {
const dto = mapAlbum(albumStub.twoAssets, false); const dto = mapAlbum(albumStub.twoAssets, false);
expect(dto.startDate).toEqual(new Date('2023-02-22T05:06:29.716Z')); expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
expect(dto.endDate).toEqual(new Date('2023-02-23T05:06:29.716Z')); expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
}); });
it('should not set start and end dates for empty assets', () => { it('should not set start and end dates for empty assets', () => {
+2 -3
View File
@@ -7,7 +7,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserRole, AssetOrder } from 'src/enum';
import { getAssetDateTime } from 'src/utils/date-time';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
export class AlbumInfoDto { export class AlbumInfoDto {
@@ -165,8 +164,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = entity.sharedLinks?.length > 0;
const hasSharedUser = sharedUsers.length > 0; const hasSharedUser = sharedUsers.length > 0;
let startDate = getAssetDateTime(assets.at(0)); let startDate = assets.at(0)?.localDateTime;
let endDate = getAssetDateTime(assets.at(-1)); let endDate = assets.at(-1)?.localDateTime;
// Swap dates if start date is greater than end date. // Swap dates if start date is greater than end date.
if (startDate && endDate && startDate > endDate) { if (startDate && endDate && startDate > endDate) {
[startDate, endDate] = [endDate, startDate]; [startDate, endDate] = [endDate, startDate];
+1 -1
View File
@@ -52,7 +52,7 @@ export class UpdateAssetBase {
@Optional() @Optional()
@IsInt() @IsInt()
@Max(5) @Max(5)
@Min(0) @Min(-1)
rating?: number; rating?: number;
} }
+22
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;
}
+1 -1
View File
@@ -193,7 +193,7 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb return qb
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId') .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'>) { export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {
+2
View File
@@ -5,6 +5,7 @@ import { APIKeyEntity } from 'src/entities/api-key.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.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 { AssetEntity } from 'src/entities/asset.entity';
import { AuditEntity } from 'src/entities/audit.entity'; import { AuditEntity } from 'src/entities/audit.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
@@ -34,6 +35,7 @@ export const entities = [
AssetEntity, AssetEntity,
AssetFaceEntity, AssetFaceEntity,
AssetFileEntity, AssetFileEntity,
AssetUserEntity,
AssetJobStatusEntity, AssetJobStatusEntity,
AuditEntity, AuditEntity,
ExifEntity, ExifEntity,
+2 -2
View File
@@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount { export interface AlbumAssetCount {
albumId: string; albumId: string;
assetCount: number; assetCount: number;
startDate: Date | undefined; startDate: Date | null;
endDate: Date | undefined; endDate: Date | null;
} }
export interface AlbumInfoOptions { export interface AlbumInfoOptions {
+1 -1
View File
@@ -13,7 +13,7 @@ if (immichApp) {
let apiProcess: ChildProcess | undefined; let apiProcess: ChildProcess | undefined;
const onError = (name: string, error: Error) => { const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}`); console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}; };
const onExit = (name: string, exitCode: number | null) => { const onExit = (name: string, exitCode: number | null) => {
@@ -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"`);
}
}
+55 -85
View File
@@ -90,7 +90,7 @@ select
( (
select select
"assets".*, "assets".*,
to_json("exif") as "exifInfo" "exif" as "exifInfo"
from from
"assets" "assets"
inner join "exif" on "assets"."id" = "exif"."assetId" inner join "exif" on "assets"."id" = "exif"."assetId"
@@ -180,19 +180,20 @@ select
) as "albumUsers" ) as "albumUsers"
from from
"albums" "albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id"
where where
( (
( "albums"."ownerId" = $1
"albums"."ownerId" = $1 or exists (
and "album_assets"."assetsId" = $2 select
) from
or ( "albums_shared_users_users" as "album_users"
"album_users"."usersId" = $3 where
and "album_assets"."assetsId" = $4 "album_users"."albumsId" = "albums"."id"
and "album_users"."usersId" = $2
) )
) )
and "album_assets"."assetsId" = $3
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
order by order by
"albums"."createdAt" desc, "albums"."createdAt" desc,
@@ -200,16 +201,17 @@ order by
-- AlbumRepository.getMetadataForIds -- AlbumRepository.getMetadataForIds
select select
"albums"."id", "albums"."id" as "albumId",
min("assets"."fileCreatedAt") as "startDate", min("assets"."localDateTime") as "startDate",
max("assets"."fileCreatedAt") as "endDate", max("assets"."localDateTime") as "endDate",
count("assets"."id") as "assetCount" count("assets"."id")::int as "assetCount"
from from
"albums" "albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "assets" on "assets"."id" = "album_assets"."assetsId" inner join "assets" on "assets"."id" = "album_assets"."assetsId"
where where
"albums"."id" in ($1) "albums"."id" in ($1)
and "assets"."deletedAt" is null
group by group by
"albums"."id" "albums"."id"
@@ -305,8 +307,8 @@ order by
"albums"."createdAt" desc "albums"."createdAt" desc
-- AlbumRepository.getShared -- AlbumRepository.getShared
select distinct select
on ("albums"."createdAt") "albums".*, "albums".*,
( (
select select
coalesce(json_agg(agg), '[]') coalesce(json_agg(agg), '[]')
@@ -389,15 +391,26 @@ select distinct
) as "sharedLinks" ) as "sharedLinks"
from from
"albums" "albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where where
( (
"shared_albums"."usersId" = $1 exists (
or "shared_links"."userId" = $2 select
or ( from
"albums"."ownerId" = $3 "albums_shared_users_users" as "album_users"
and "shared_albums"."usersId" is not null where
"album_users"."albumsId" = "albums"."id"
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
)
or exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
and "shared_links"."userId" = $3
) )
) )
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
@@ -405,48 +418,8 @@ order by
"albums"."createdAt" desc "albums"."createdAt" desc
-- AlbumRepository.getNotShared -- AlbumRepository.getNotShared
select distinct select
on ("albums"."createdAt") "albums".*, "albums".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_users".*,
(
select
to_json(obj)
from
(
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "album_users"."usersId"
) as obj
) as "user"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
) as agg
) as "albumUsers",
( (
select select
to_json(obj) to_json(obj)
@@ -473,29 +446,26 @@ select distinct
where where
"users"."id" = "albums"."ownerId" "users"."id" = "albums"."ownerId"
) as obj ) as obj
) as "owner", ) as "owner"
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from from
"albums" "albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where where
"albums"."ownerId" = $1 "albums"."ownerId" = $1
and "shared_albums"."usersId" is null
and "shared_links"."userId" is null
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
and not exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
)
and not exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
)
order by order by
"albums"."createdAt" desc "albums"."createdAt" desc
+2 -2
View File
@@ -36,7 +36,7 @@ offset
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."id" < $6 and "assets"."id" < $6
order by order by
"assets"."id" random()
limit limit
$7 $7
) )
@@ -56,7 +56,7 @@ union all
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."id" > $13 and "assets"."id" > $13
order by order by
"assets"."id" random()
limit limit
$14 $14
) )
+43 -35
View File
@@ -59,7 +59,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.innerJoin('exif', 'assets.id', 'exif.assetId') .innerJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson('exif').as('exifInfo')) .select((eb) => eb.table('exif').as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
@@ -93,14 +93,19 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id')
.where((eb) => .where((eb) =>
eb.or([ eb.or([
eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), eb('albums.ownerId', '=', ownerId),
eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]), eb.exists(
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id')
.where('album_users.usersId', '=', ownerId),
),
]), ]),
) )
.where('album_assets.assetsId', '=', assetId)
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.select(withOwner) .select(withOwner)
@@ -117,24 +122,18 @@ export class AlbumRepository implements IAlbumRepository {
return []; return [];
} }
const metadatas = await this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') .innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('assets', 'assets.id', 'album_assets.assetsId') .innerJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id') .select('albums.id as albumId')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate')) .select((eb) => eb.fn.min('assets.localDateTime').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) .select((eb) => eb.fn.max('assets.localDateTime').as('endDate'))
.select((eb) => eb.fn.count('assets.id').as('assetCount')) .select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
.where('albums.id', 'in', ids) .where('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null)
.groupBy('albums.id') .groupBy('albums.id')
.execute(); .execute();
return metadatas.map((metadatas) => ({
albumId: metadatas.id,
assetCount: Number(metadatas.assetCount),
startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined,
endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined,
}));
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
@@ -159,14 +158,20 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where((eb) => .where((eb) =>
eb.or([ eb.or([
eb('shared_albums.usersId', '=', ownerId), eb.exists(
eb('shared_links.userId', '=', ownerId), eb
eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]), .selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id')
.where((eb) => eb.or([eb('albums.ownerId', '=', ownerId), eb('album_users.usersId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_links')
.whereRef('shared_links.albumId', '=', 'albums.id')
.where('shared_links.userId', '=', ownerId),
),
]), ]),
) )
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
@@ -185,16 +190,21 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('albums') .selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where('albums.ownerId', '=', ownerId) .where('albums.ownerId', '=', ownerId)
.where('shared_albums.usersId', 'is', null)
.where('shared_links.userId', 'is', null)
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.select(withAlbumUsers) .where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id'),
),
),
)
.where((eb) =>
eb.not(eb.exists(eb.selectFrom('shared_links').whereRef('shared_links.albumId', '=', 'albums.id'))),
)
.select(withOwner) .select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute() as unknown as Promise<AlbumEntity[]>;
} }
@@ -281,7 +291,6 @@ export class AlbumRepository implements IAlbumRepository {
.selectAll() .selectAll()
.where('id', '=', newAlbum.id) .where('id', '=', newAlbum.id)
.select(withOwner) .select(withOwner)
.select(withSharedLink)
.select(withAssets) .select(withAssets)
.select(withAlbumUsers) .select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>; .executeTakeFirst() as unknown as Promise<AlbumEntity>;
@@ -291,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> { update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.db return this.db
.updateTable('albums') .updateTable('albums')
.set({ ...album, updatedAt: new Date() }) .set(album)
.where('id', '=', id) .where('id', '=', id)
.returningAll('albums') .returningAll('albums')
.returning(withOwner) .returning(withOwner)
@@ -334,7 +343,6 @@ export class AlbumRepository implements IAlbumRepository {
.select('album_assets.assetsId') .select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc') .orderBy('assets.fileCreatedAt', 'desc')
.limit(1), .limit(1),
updatedAt: new Date(),
})) }))
.where((eb) => .where((eb) =>
eb.or([ eb.or([
+18 -2
View File
@@ -81,7 +81,24 @@ export class AssetRepository implements IAssetRepository {
} }
create(asset: Insertable<Assets>): Promise<AssetEntity> { 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 }] }) @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
@@ -495,7 +512,6 @@ export class AssetRepository implements IAssetRepository {
.$if(property === WithoutProperty.THUMBNAIL, (qb) => .$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.select(withFiles)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where((eb) => .where((eb) =>
eb.or([ eb.or([
@@ -172,6 +172,28 @@ describe('getEnv', () => {
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
}); });
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
});
});
}); });
describe('redis', () => { describe('redis', () => {
+1 -2
View File
@@ -100,7 +100,6 @@ export class PersonRepository implements IPersonRepository {
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!)) .$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) .$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!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<AssetFaceEntity>; .stream() as AsyncIterableIterator<AssetFaceEntity>;
} }
@@ -109,7 +108,7 @@ export class PersonRepository implements IPersonRepository {
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) .$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 === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
+9 -3
View File
@@ -72,8 +72,14 @@ export class SearchRepository implements ISearchRepository {
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> { async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
const uuid = randomUUID(); const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options); const builder = searchAssetBuilder(this.db, options);
const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size); const lessThan = builder
const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size); .where('assets.id', '<', uuid)
.orderBy(sql`random()`)
.limit(size);
const greaterThan = builder
.where('assets.id', '>', uuid)
.orderBy(sql`random()`)
.limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db); const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[]; return rows as any as AssetEntity[];
} }
@@ -292,7 +298,7 @@ export class SearchRepository implements ISearchRepository {
await sql`truncate ${sql.table('smart_search')}`.execute(trx); await sql`truncate ${sql.table('smart_search')}`.execute(trx);
await trx.schema await trx.schema
.alterTable('smart_search') .alterTable('smart_search')
.alterColumn('embedding', (col) => col.setDataType(sql.lit(`vector(${dimSize})`))) .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
.execute(); .execute();
await sql`reindex index clip_index`.execute(trx); await sql`reindex index clip_index`.execute(trx);
}); });
+4 -4
View File
@@ -52,8 +52,8 @@ describe(AlbumService.name, () => {
it('gets list of albums for auth user', async () => { it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
const result = await sut.getAll(authStub.admin, {}); const result = await sut.getAll(authStub.admin, {});
@@ -82,7 +82,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => { it('gets list of albums that are shared', async () => {
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
const result = await sut.getAll(authStub.admin, { shared: true }); const result = await sut.getAll(authStub.admin, { shared: true });
@@ -94,7 +94,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => { it('gets list of albums that are NOT shared', async () => {
albumMock.getNotShared.mockResolvedValue([albumStub.empty]); albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
albumMock.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
]); ]);
const result = await sut.getAll(authStub.admin, { shared: false }); const result = await sut.getAll(authStub.admin, { shared: false });
+7 -13
View File
@@ -55,13 +55,7 @@ export class AlbumService extends BaseService {
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {}; const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) { for (const metadata of results) {
const { albumId, assetCount, startDate, endDate } = metadata; albumMetadata[metadata.albumId] = metadata;
albumMetadata[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
} }
return Promise.all( return Promise.all(
@@ -70,9 +64,9 @@ export class AlbumService extends BaseService {
return { return {
...mapAlbumWithoutAssets(album), ...mapAlbumWithoutAssets(album),
sharedLinks: undefined, sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate, startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id].endDate, endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id].assetCount, assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
}; };
}), }),
@@ -89,9 +83,9 @@ export class AlbumService extends BaseService {
return { return {
...mapAlbum(album, withAssets, auth), ...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate, startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds.endDate, endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds.assetCount, assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
}; };
} }
+28
View File
@@ -416,6 +416,34 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
}); });
it('should not update Assets table if no relevant fields are provided', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: undefined,
duplicateId: undefined,
rating: undefined,
});
expect(assetMock.updateAll).not.toHaveBeenCalled();
});
it('should update Assets table if isArchived field is provided', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: false,
duplicateId: undefined,
rating: undefined,
});
expect(assetMock.updateAll).toHaveBeenCalled();
});
}); });
describe('deleteAll', () => { describe('deleteAll', () => {
+8 -1
View File
@@ -142,7 +142,14 @@ export class AssetService extends BaseService {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
} }
await this.assetRepository.updateAll(ids, options); if (
options.isArchived != undefined ||
options.isFavorite != undefined ||
options.duplicateId != undefined ||
options.rating != undefined
) {
await this.assetRepository.updateAll(ids, options);
}
} }
@OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK }) @OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK })
+20 -2
View File
@@ -337,12 +337,31 @@ describe(LibraryService.name, () => {
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], {
deletedAt: null, deletedAt: null,
fileCreatedAt: assetStub.trashedOffline.fileModifiedAt,
fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, fileModifiedAt: assetStub.trashedOffline.fileModifiedAt,
isOffline: false, isOffline: false,
originalFileName: 'path.jpg', originalFileName: 'path.jpg',
}); });
}); });
it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.trashedOffline);
storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith(
[assetStub.trashedOffline.id],
expect.not.objectContaining({
fileCreatedAt: expect.anything(),
}),
);
});
}); });
it('should update file when mtime has changed', async () => { it('should update file when mtime has changed', async () => {
@@ -360,7 +379,6 @@ describe(LibraryService.name, () => {
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
fileModifiedAt: newMTime, fileModifiedAt: newMTime,
fileCreatedAt: newMTime,
isOffline: false, isOffline: false,
originalFileName: 'photo.jpg', originalFileName: 'photo.jpg',
deletedAt: null, deletedAt: null,
-1
View File
@@ -511,7 +511,6 @@ export class LibraryService extends BaseService {
await this.assetRepository.updateAll([asset.id], { await this.assetRepository.updateAll([asset.id], {
isOffline: false, isOffline: false,
deletedAt: null, deletedAt: null,
fileCreatedAt: mtime,
fileModifiedAt: mtime, fileModifiedAt: mtime,
originalFileName: parse(asset.originalPath).base, originalFileName: parse(asset.originalPath).base,
}); });
+1 -1
View File
@@ -194,7 +194,7 @@ export class MediaService extends BaseService {
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); 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 }); await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
} }
+13 -2
View File
@@ -335,8 +335,8 @@ describe(MetadataService.name, () => {
expect(assetMock.update).toHaveBeenCalledWith({ expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
duration: null, duration: null,
fileCreatedAt: assetStub.image.createdAt, fileCreatedAt: assetStub.image.fileCreatedAt,
localDateTime: new Date('2023-02-23T05:06:29.716Z'), 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', () => { describe('handleQueueSidecar', () => {
+1 -1
View File
@@ -204,7 +204,7 @@ export class MetadataService extends BaseService {
// comments // comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null, profileDescription: exifTags.ProfileDescription || null,
rating: validateRange(exifTags.Rating, 0, 5), rating: validateRange(exifTags.Rating, -1, 5),
// grouping // grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
-5
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;
};
+1 -1
View File
@@ -62,7 +62,7 @@ async function bootstrap() {
app.use(app.get(ApiService).ssr(excludePaths)); app.use(app.get(ApiService).ssr(excludePaths));
const server = await (host ? app.listen(port, host) : app.listen(port)); const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 30 * 60 * 1000; server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
} }
+2 -2
View File
@@ -210,7 +210,7 @@ export const assetStub = {
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'), createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: 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, isFavorite: true,
isArchived: false, isArchived: false,
duration: null, duration: null,
@@ -574,7 +574,7 @@ export const assetStub = {
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-22T05:06:29.716Z'), createdAt: new Date('2023-02-22T05:06:29.716Z'),
updatedAt: 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, isFavorite: false,
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
+1 -1
View File
@@ -311,7 +311,7 @@ export const sharedLinkResponseStub = {
allowUpload: false, allowUpload: false,
allowDownload: false, allowDownload: false,
showMetadata: false, showMetadata: false,
album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime },
assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }], assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
}), }),
}; };
@@ -4,7 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return { return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), 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: {} }), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(false), extract: vitest.fn().mockResolvedValue(false),
probe: vitest.fn(), probe: vitest.fn(),
+24 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.125.2", "version": "1.125.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -16,6 +16,8 @@
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^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", "@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0", "@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
@@ -75,7 +77,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
@@ -1669,6 +1671,25 @@
"@photo-sphere-viewer/video-plugin": "5.11.5" "@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": { "node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.11.5", "version": "5.11.5",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz", "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.5.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.125.2", "version": "1.125.6",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -72,6 +72,8 @@
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5", "@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^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", "@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0", "@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
@@ -11,7 +11,10 @@
let { album }: Props = $props(); let { album }: Props = $props();
const formatDate = (date?: string) => { 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) => { const getDateRange = (start?: string, end?: string) => {
@@ -24,7 +24,7 @@
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer <PhotoSphereViewer
panorama={data} panorama={data}
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined} originalPanorama={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
/> />
{:catch} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
@@ -7,18 +7,21 @@
type AdapterConstructor, type AdapterConstructor,
type PluginConstructor, type PluginConstructor,
} from '@photo-sphere-viewer/core'; } 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/core/index.css';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
interface Props { interface Props {
panorama: string | { source: string }; panorama: string | { source: string };
originalImageUrl?: string; originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean; 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 container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
@@ -30,9 +33,33 @@
viewer = new Viewer({ viewer = new Viewer({
adapter, 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, container,
panorama,
touchmoveTwoFingers: false, touchmoveTwoFingers: false,
mousewheelCtrlKey: false, mousewheelCtrlKey: false,
navbar, navbar,
@@ -40,15 +67,14 @@
maxFov: 120, maxFov: 120,
fisheye: false, fisheye: false,
}); });
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
if (originalImageUrl && !$alwaysLoadOriginalFile) { if (originalPanorama && !$alwaysLoadOriginalFile) {
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100] // zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) { if (Math.round(zoomLevel) >= 75) {
// Replace the preview with the original // Replace the preview with the original
viewer.setPanorama(originalImageUrl, { showLoader: false, speed: 150 }).catch(() => { void resolutionPlugin.setResolution('original');
viewer.setPanorama(panorama, { showLoader: false, speed: 0 }).catch(() => {});
});
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
} }
}; };
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetOriginalUrl } from '$lib/utils';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -22,7 +22,13 @@
{#await modules} {#await modules}
<LoadingSpinner /> <LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]} {: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} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
{/await} {/await}
@@ -11,7 +11,11 @@
<section class="min-w-screen flex min-h-dvh items-center justify-center relative"> <section class="min-w-screen flex min-h-dvh items-center justify-center relative">
<div class="absolute -z-10 w-full h-full flex place-items-center place-content-center"> <div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
<img src={immichLogo} class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10" alt="Immich logo" /> <img
src={immichLogo}
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
alt="Immich logo"
/>
<div <div
class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20" class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20"
></div> ></div>
@@ -15,7 +15,7 @@
</script> </script>
<ul class="list-none ml-2"> <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 value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)} {@const key = value + getColor(value)}
{#key key} {#key key}
-1
View File
@@ -24,7 +24,6 @@ class FoldersStore {
const uniquePaths = await getUniqueOriginalPaths(); const uniquePaths = await getUniqueOriginalPaths();
this.uniquePaths.push(...uniquePaths); this.uniquePaths.push(...uniquePaths);
this.uniquePaths.sort();
} }
bustAssetCache() { bustAssetCache() {
-1
View File
@@ -157,7 +157,6 @@ async function fileUploader(
} }
} catch (error) { } catch (error) {
console.error(`Error calculating sha1 file=${assetFile.name})`, error); console.error(`Error calculating sha1 file=${assetFile.name})`, error);
throw error;
} }
} }
@@ -44,7 +44,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []); let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths)); let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); 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(); const assetInteraction = new AssetInteraction();
@@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => {
return { return {
asset, asset,
path, path,
currentFolders: Object.keys(tree || {}), currentFolders: Object.keys(tree || {}).sort(),
pathAssets, pathAssets,
meta: { meta: {
title: $t('folders'), title: $t('folders'),
@@ -92,6 +92,7 @@
let personMerge1: PersonResponseDto | undefined = $state(); let personMerge1: PersonResponseDto | undefined = $state();
let personMerge2: PersonResponseDto | undefined = $state(); let personMerge2: PersonResponseDto | undefined = $state();
let potentialMergePeople: PersonResponseDto[] = $state([]); let potentialMergePeople: PersonResponseDto[] = $state([]);
let isSuggestionSelectedByUser = $state(false);
let personName = ''; let personName = '';
let suggestedPeople: PersonResponseDto[] = $state([]); let suggestedPeople: PersonResponseDto[] = $state([]);
@@ -233,15 +234,22 @@
personName = person.name; personName = person.name;
personMerge1 = person; personMerge1 = person;
personMerge2 = person2; personMerge2 = person2;
isSuggestionSelectedByUser = true;
viewMode = PersonPageViewMode.SUGGEST_MERGE; viewMode = PersonPageViewMode.SUGGEST_MERGE;
}; };
const changeName = async () => { const changeName = async () => {
viewMode = PersonPageViewMode.VIEW_ASSETS; viewMode = PersonPageViewMode.VIEW_ASSETS;
person.name = personName; 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 } }); person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
notificationController.show({ notificationController.show({