Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran
b192dfff38 wip 2025-03-01 13:53:22 -06:00
29 changed files with 134 additions and 77 deletions

6
cli/package-lock.json generated
View File

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

View File

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

View File

@@ -1,8 +1,4 @@
[
{
"label": "v1.128.0",
"url": "https://v1.128.0.archive.immich.app"
},
{
"label": "v1.127.0",
"url": "https://v1.127.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
@@ -6,6 +6,8 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/trash', () => {
let admin: LoginResponseDto;
let ws: Socket;
@@ -79,7 +81,8 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1);
@@ -87,7 +90,8 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -112,7 +116,8 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1);
@@ -120,7 +125,8 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -174,7 +180,8 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
@@ -182,7 +189,9 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
@@ -192,8 +201,6 @@ describe('/trash', () => {
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
});
});
@@ -231,7 +238,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -240,7 +247,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await utils.scan(admin.accessToken, library.id);
await scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await utils.getAssetInfo(admin.accessToken, assetId);
@@ -254,8 +261,6 @@ describe('/trash', () => {
const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(true);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.128.0+186
version: 1.127.0+185
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -7655,7 +7655,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.128.0",
"version": "1.127.0",
"contact": {}
},
"tags": [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,10 +55,9 @@ with
inner join "exif" on "a"."id" = "exif"."assetId"
)
select
date_part(
'year',
("localDateTime" at time zone 'UTC')::date
)::int as "year",
(
(now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date
) / 365 as "yearsAgo",
json_agg("res") as "assets"
from
"res"

View File

@@ -192,7 +192,7 @@ export class AssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) {
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<DayOfYearAssets[]> {
return this.db
.with('res', (qb) =>
qb
@@ -239,12 +239,16 @@ export class AssetRepository {
.select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')),
)
.selectFrom('res')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
.select(
sql<number>`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as(
'yearsAgo',
),
)
.select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets'))
.groupBy(sql`("localDateTime" at time zone 'UTC')::date`)
.orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(10)
.execute();
.execute() as any as Promise<DayOfYearAssets[]>;
}
@GenerateSql({ params: [[DummyValue.UUID]] })

View File

@@ -64,18 +64,18 @@ describe(AssetService.name, () => {
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getByDayOfYear.mockResolvedValue([
{
year: 2023,
yearsAgo: 1,
assets: [image1, image2],
},
{
year: 2015,
yearsAgo: 9,
assets: [image3],
},
{
year: 2009,
yearsAgo: 15,
assets: [image4],
},
] as any);
]);
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
{ yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] },

View File

@@ -38,15 +38,12 @@ export class AssetService extends BaseService {
const userIds = [auth.user.id, ...partnerIds];
const groups = await this.assetRepository.getByDayOfYear(userIds, dto);
return groups.map(({ year, assets }) => {
const yearsAgo = DateTime.utc().year - year;
return {
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })),
};
});
return groups.map(({ yearsAgo, assets }) => ({
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset, { auth })),
}));
}
async getStatistics(auth: AuthDto, dto: AssetStatsDto) {

View File

@@ -195,11 +195,7 @@ export class JobService extends BaseService {
await this.onDone(job);
}
} catch (error: Error | any) {
this.logger.error(
`Unable to run job handler (${queueName}/${job.name}): ${error}`,
error?.stack,
JSON.stringify(job.data),
);
this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data);
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}

View File

@@ -30,33 +30,35 @@ export class MemoryService extends BaseService {
const start = DateTime.utc().startOf('day').minus({ days: DAYS });
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE);
const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start;
let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start;
// generate a memory +/- X days from today
for (let i = 0; i <= DAYS * 2; i++) {
for (let i = 0; i <= DAYS * 2 + 1; i++) {
const target = start.plus({ days: i });
if (lastOnThisDayDate >= target) {
if (lastOnThisDayDate > target) {
continue;
}
const showAt = target.startOf('day').toISO();
const hideAt = target.endOf('day').toISO();
this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`);
for (const [userId, userIds] of Object.entries(userMap)) {
const memories = await this.assetRepository.getByDayOfYear(userIds, target);
for (const { year, assets } of memories) {
const data: OnThisDayData = { year };
for (const memory of memories) {
const data: OnThisDayData = { year: target.year - memory.yearsAgo };
await this.memoryRepository.create(
{
ownerId: userId,
type: MemoryType.ON_THIS_DAY,
data,
memoryAt: target.set({ year }).toISO(),
memoryAt: target.minus({ years: memory.yearsAgo }).toISO(),
showAt,
hideAt,
},
new Set(assets.map(({ id }) => id)),
new Set(memory.assets.map(({ id }) => id)),
);
}
}
@@ -65,6 +67,8 @@ export class MemoryService extends BaseService {
...state,
lastOnThisDayDate: target.toISO(),
});
lastOnThisDayDate = target;
}
}

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.128.0",
"version": "1.127.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.128.0",
"version": "1.127.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -78,7 +78,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.128.0",
"version": "1.127.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.128.0",
"version": "1.127.0",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",

View File

@@ -8,12 +8,14 @@
import { preferences, user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk';
import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
import { mdiCog, mdiLogout, mdiPencil, mdiQrcode, mdiWrench } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { NotificationType, notificationController } from '../notification/notification';
import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.svelte';
import { IconButton } from '@immich/ui';
import { qrCodeLoginStore } from '$lib/stores/qrcode-login.svelte';
interface Props {
onLogout: () => void;
@@ -51,6 +53,14 @@
class="absolute right-[25px] top-[75px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray"
use:focusTrap
>
<div class="absolute right-8 top-8">
<IconButton
icon={mdiQrcode}
shape="round"
aria-label="Signin With QR Code"
onclick={() => qrCodeLoginStore.showForm()}
/>
</div>
<div
class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-3xl bg-white p-4 dark:bg-immich-dark-primary/10"
>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { qrCodeLoginStore } from '$lib/stores/qrcode-login.svelte';
import { Button, Input } from '@immich/ui';
let password = $state('');
const submitPassword = (event: Event) => {
event.preventDefault();
console.log('Password submitted:', password);
};
</script>
{#if qrCodeLoginStore.shouldShowForm}
<div id="instance-qr-login">
<FullScreenModal title={'Login QR Code'} onClose={() => {}}>
<p class="text-xs">Enter your password to show the QR code</p>
<div class="mt-4">
<form onsubmit={submitPassword}>
<Input size="small" placeholder="Password" type="password" bind:value={password} required />
<div class="mt-6">
<Button type="submit" size="small" shape="round" fullWidth>Confirm</Button>
</div>
</form>
</div>
</FullScreenModal>
</div>
{/if}

View File

@@ -0,0 +1,13 @@
class QRCodeLoginStore {
shouldShowForm = $state(false);
showForm() {
this.shouldShowForm = true;
}
hideForm() {
this.shouldShowForm = false;
}
}
export const qrCodeLoginStore = new QRCodeLoginStore();

View File

@@ -22,6 +22,7 @@
import { setTranslations } from '@immich/ui';
import '../app.css';
import { t } from 'svelte-i18n';
import QRCodeLoginForm from '$lib/components/shared-components/qrcode-login-form.svelte';
interface Props {
children?: Snippet;
@@ -154,6 +155,7 @@
<UploadPanel />
<NotificationList />
<DialogWrapper />
<QRCodeLoginForm />
{#if $user?.isAdmin}
<VersionAnnouncementBox />