1
0
forked from Cutlery/immich

Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job

This commit is contained in:
Jonathan Jogenfors 2024-03-30 21:52:16 +01:00
commit 5515f57c09
120 changed files with 2953 additions and 2085 deletions

12
cli/package-lock.json generated
View File

@ -47,15 +47,15 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.11.30",
"typescript": "^5.4.3"
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -4456,9 +4456,9 @@
}
},
"node_modules/vite": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz",
"integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.3.tgz",
"integrity": "sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",

View File

@ -1,15 +1,24 @@
import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
await authenticate(options);
const { url } = await authenticate(options);
const versionInfo = await getServerVersion();
const mediaTypes = await getSupportedMediaTypes();
const stats = await getAssetStatistics({});
const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUserInfo(),
]);
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(`Statistics:\n Images: ${stats.images}\n Videos: ${stats.videos}\n Total: ${stats.total}`);
console.log(`Server Info (via ${userInfo.email})`);
console.log(` Url: ${url}`);
console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(` Formats:`);
console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(` Statistics:`);
console.log(` Images: ${stats.images}`);
console.log(` Videos: ${stats.videos}`);
console.log(` Total: ${stats.total}`);
};

View File

@ -15,21 +15,25 @@ export interface BaseOptions {
export type AuthDto = { url: string; key: string };
type OldAuthDto = { instanceUrl: string; apiKey: string };
export const authenticate = async (options: BaseOptions): Promise<void> => {
export const authenticate = async (options: BaseOptions): Promise<AuthDto> => {
const { configDirectory: configDir, url, key } = options;
// provided in command
if (url && key) {
await connect(url, key);
return;
return connect(url, key);
}
// fallback to auth file
const config = await readAuthFile(configDir);
await connect(config.url, config.key);
const auth = await connect(config.url, config.key);
if (auth.url !== config.url) {
await writeAuthFile(configDir, auth);
}
return auth;
};
export const connect = async (url: string, key: string): Promise<void> => {
export const connect = async (url: string, key: string) => {
const wellKnownUrl = new URL('.well-known/immich', url);
try {
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
@ -50,6 +54,8 @@ export const connect = async (url: string, key: string): Promise<void> => {
logError(error, 'Failed to connect to server');
process.exit(1);
}
return { url, key };
};
export const logError = (error: unknown, message: string) => {

View File

@ -20,7 +20,7 @@ The recommended way to backup and restore the Immich database is to use the `pg_
<Tabs>
<TabItem value="Linux system based Backup" label="Linux system based Backup" default>
```bash title='Bash'
```bash title='Backup'
docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/backup/dump.sql.gz"
```

6
e2e/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.99.0",
"version": "1.100.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.99.0",
"version": "1.100.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
@ -80,7 +80,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

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

View File

@ -5,7 +5,6 @@ import {
LibraryResponseDto,
LoginResponseDto,
SharedLinkType,
TimeBucketSize,
getAllLibraries,
getAssetInfo,
updateAssets,
@ -942,146 +941,6 @@ describe('/asset', () => {
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id),
});
const { status, body } = await request(app)
.get('/asset/time-buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
});
describe('GET /asset/time-bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01T00:00:00.000Z',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/asset/time-bucket')
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /asset', () => {
it('should return stack data', async () => {
const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`);

View File

@ -1,4 +1,4 @@
import { LoginResponseDto, getConfig } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, getConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
@ -10,11 +10,14 @@ const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAu
describe('/system-config', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
asset = await utils.createAsset(admin.accessToken);
});
describe('GET /system-config/map/style.json', () => {
@ -24,6 +27,19 @@ describe('/system-config', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app)
.get(`/system-config/map/style.json?key=${sharedLink.key}`)
.query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)

View File

@ -0,0 +1,193 @@
import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
// TODO this should probably be a test util function
const today = DateTime.fromObject({
year: 2023,
month: 11,
day: 3,
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
describe('/timeline', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let userAssets: AssetFileUploadResponseDto[];
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[user, timeBucketUser] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
]);
userAssets = await Promise.all([
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken, {
isFavorite: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
]);
});
describe('GET /timeline/buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user.accessToken, {
type: SharedLinkType.Individual,
assetIds: userAssets.map(({ id }) => id),
});
const { status, body } = await request(app)
.get('/timeline/buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /timeline/bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01T00:00:00.000Z',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
expect(status).toBe(200);
expect(body).toEqual([]);
});
});
});

View File

@ -11,13 +11,16 @@ describe(`immich server-info`, () => {
it('should return the server info', async () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Version:'),
expect.stringContaining('Image Types:'),
expect.stringContaining('Video Types:'),
'Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
expect.stringContaining('Server Info (via admin@immich.cloud'),
' Url: http://127.0.0.1:2283/api',
expect.stringContaining('Version:'),
' Formats:',
expect.stringContaining('Images:'),
expect.stringContaining('Videos:'),
' Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.99.0"
version = "1.100.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" => 129,
"android.injected.version.name" => "1.99.0",
"android.injected.version.code" => 130,
"android.injected.version.name" => "1.100.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

@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.32752">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.774783">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="26.041597">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="32.283066">
</testcase>

View File

@ -151,7 +151,7 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: 13825b8a9334a850581300559b8839134b124670
@ -175,4 +175,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.15.2
COCOAPODS: 1.12.1

View File

@ -383,7 +383,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -525,7 +525,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -553,7 +553,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@ -55,11 +55,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.99.0</string>
<string>1.100.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>145</string>
<string>146</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.99.0"
version_number: "1.100.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000246">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000226">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.175843">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.183824">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.871371">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.799845">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.189451">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.185425">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="142.078248">
<testcase classname="fastlane.lanes" name="4: build_app" time="111.245268">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="78.774821">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="72.572736">
</testcase>

View File

@ -169,6 +169,7 @@ doc/TagTypeEnum.md
doc/ThumbnailFormat.md
doc/TimeBucketResponseDto.md
doc/TimeBucketSize.md
doc/TimelineApi.md
doc/ToneMapping.md
doc/TranscodeHWAccel.md
doc/TranscodePolicy.md
@ -211,6 +212,7 @@ lib/api/server_info_api.dart
lib/api/shared_link_api.dart
lib/api/system_config_api.dart
lib/api/tag_api.dart
lib/api/timeline_api.dart
lib/api/trash_api.dart
lib/api/user_api.dart
lib/api_client.dart
@ -556,6 +558,7 @@ test/tag_type_enum_test.dart
test/thumbnail_format_test.dart
test/time_bucket_response_dto_test.dart
test/time_bucket_size_test.dart
test/timeline_api_test.dart
test/tone_mapping_test.dart
test/transcode_hw_accel_test.dart
test/transcode_policy_test.dart

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.99.0
- API version: 1.100.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@ -105,8 +105,6 @@ Class | Method | HTTP request | Description
*AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |
*AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |
*AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random |
*AssetApi* | [**getTimeBucket**](doc//AssetApi.md#gettimebucket) | **GET** /asset/time-bucket |
*AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets |
*AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs |
*AssetApi* | [**searchAssets**](doc//AssetApi.md#searchassets) | **GET** /assets |
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
@ -199,6 +197,8 @@ Class | Method | HTTP request | Description
*TagApi* | [**tagAssets**](doc//TagApi.md#tagassets) | **PUT** /tag/{id}/assets |
*TagApi* | [**untagAssets**](doc//TagApi.md#untagassets) | **DELETE** /tag/{id}/assets |
*TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} |
*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket |
*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets |
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets |
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore |

View File

@ -23,8 +23,6 @@ Method | HTTP request | Description
[**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |
[**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |
[**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random |
[**getTimeBucket**](AssetApi.md#gettimebucket) | **GET** /asset/time-bucket |
[**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets |
[**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs |
[**searchAssets**](AssetApi.md#searchassets) | **GET** /assets |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
@ -833,158 +831,6 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getTimeBucket**
> List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final size = ; // TimeBucketSize |
final timeBucket = timeBucket_example; // String |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getTimeBucket: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**timeBucket** | **String**| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<AssetResponseDto>**](AssetResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getTimeBuckets**
> List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final size = ; // TimeBucketSize |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getTimeBuckets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<TimeBucketResponseDto>**](TimeBucketResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **runAssetJobs**
> runAssetJobs(assetJobsDto)

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [default to const []]
**title** | **String** | |
**yearsAgo** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -119,7 +119,7 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getMapStyle**
> Object getMapStyle(theme)
> Object getMapStyle(theme, key)
@ -143,9 +143,10 @@ import 'package:openapi/api.dart';
final api_instance = SystemConfigApi();
final theme = ; // MapTheme |
final key = key_example; // String |
try {
final result = api_instance.getMapStyle(theme);
final result = api_instance.getMapStyle(theme, key);
print(result);
} catch (e) {
print('Exception when calling SystemConfigApi->getMapStyle: $e\n');
@ -157,6 +158,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**theme** | [**MapTheme**](.md)| |
**key** | **String**| | [optional]
### Return type

167
mobile/openapi/doc/TimelineApi.md generated Normal file
View File

@ -0,0 +1,167 @@
# openapi.api.TimelineApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getTimeBucket**](TimelineApi.md#gettimebucket) | **GET** /timeline/bucket |
[**getTimeBuckets**](TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets |
# **getTimeBucket**
> List<AssetResponseDto> getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = TimelineApi();
final size = ; // TimeBucketSize |
final timeBucket = timeBucket_example; // String |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling TimelineApi->getTimeBucket: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**timeBucket** | **String**| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<AssetResponseDto>**](AssetResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getTimeBuckets**
> List<TimeBucketResponseDto> getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = TimelineApi();
final size = ; // TimeBucketSize |
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isArchived = true; // bool |
final isFavorite = true; // bool |
final isTrashed = true; // bool |
final key = key_example; // String |
final order = ; // AssetOrder |
final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final withPartners = true; // bool |
final withStacked = true; // bool |
try {
final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked);
print(result);
} catch (e) {
print('Exception when calling TimelineApi->getTimeBuckets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**size** | [**TimeBucketSize**](.md)| |
**albumId** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isTrashed** | **bool**| | [optional]
**key** | **String**| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**personId** | **String**| | [optional]
**userId** | **String**| | [optional]
**withPartners** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
### Return type
[**List<TimeBucketResponseDto>**](TimeBucketResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@ -47,6 +47,7 @@ part 'api/server_info_api.dart';
part 'api/shared_link_api.dart';
part 'api/system_config_api.dart';
part 'api/tag_api.dart';
part 'api/timeline_api.dart';
part 'api/trash_api.dart';
part 'api/user_api.dart';

View File

@ -835,255 +835,6 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/time-bucket' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/time-bucket';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/time-buckets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List)
.cast<TimeBucketResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
/// Parameters:
///

View File

@ -102,7 +102,9 @@ class SystemConfigApi {
/// Parameters:
///
/// * [MapTheme] theme (required):
Future<Response> getMapStyleWithHttpInfo(MapTheme theme,) async {
///
/// * [String] key:
Future<Response> getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/system-config/map/style.json';
@ -113,6 +115,9 @@ class SystemConfigApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
queryParams.addAll(_queryParams('', 'theme', theme));
const contentTypes = <String>[];
@ -132,8 +137,10 @@ class SystemConfigApi {
/// Parameters:
///
/// * [MapTheme] theme (required):
Future<Object?> getMapStyle(MapTheme theme,) async {
final response = await getMapStyleWithHttpInfo(theme,);
///
/// * [String] key:
Future<Object?> getMapStyle(MapTheme theme, { String? key, }) async {
final response = await getMapStyleWithHttpInfo(theme, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

267
mobile/openapi/lib/api/timeline_api.dart generated Normal file
View File

@ -0,0 +1,267 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class TimelineApi {
TimelineApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/bucket';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final path = r'/timeline/buckets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
queryParams.addAll(_queryParams('', 'size', size));
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
if (withPartners != null) {
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
}
if (withStacked != null) {
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
///
/// * [bool] isTrashed:
///
/// * [String] key:
///
/// * [AssetOrder] order:
///
/// * [String] personId:
///
/// * [String] userId:
///
/// * [bool] withPartners:
///
/// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List)
.cast<TimeBucketResponseDto>()
.toList(growable: false);
}
return null;
}
}

View File

@ -15,30 +15,36 @@ class MemoryLaneResponseDto {
MemoryLaneResponseDto({
this.assets = const [],
required this.title,
required this.yearsAgo,
});
List<AssetResponseDto> assets;
String title;
int yearsAgo;
@override
bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto &&
_deepEquality.equals(other.assets, assets) &&
other.title == title;
other.title == title &&
other.yearsAgo == yearsAgo;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assets.hashCode) +
(title.hashCode);
(title.hashCode) +
(yearsAgo.hashCode);
@override
String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title]';
String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title, yearsAgo=$yearsAgo]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assets'] = this.assets;
json[r'title'] = this.title;
json[r'yearsAgo'] = this.yearsAgo;
return json;
}
@ -52,6 +58,7 @@ class MemoryLaneResponseDto {
return MemoryLaneResponseDto(
assets: AssetResponseDto.listFromJson(json[r'assets']),
title: mapValueOfType<String>(json, r'title')!,
yearsAgo: mapValueOfType<int>(json, r'yearsAgo')!,
);
}
return null;
@ -101,6 +108,7 @@ class MemoryLaneResponseDto {
static const requiredKeys = <String>{
'assets',
'title',
'yearsAgo',
};
}

View File

@ -95,16 +95,6 @@ void main() {
// TODO
});
//Future<List<AssetResponseDto>> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBucket', () async {
// TODO
});
//Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBuckets', () async {
// TODO
});
//Future runAssetJobs(AssetJobsDto assetJobsDto) async
test('test runAssetJobs', () async {
// TODO

View File

@ -26,6 +26,11 @@ void main() {
// TODO
});
// int yearsAgo
test('to test the property `yearsAgo`', () async {
// TODO
});
});

View File

@ -27,7 +27,7 @@ void main() {
// TODO
});
//Future<Object> getMapStyle(MapTheme theme) async
//Future<Object> getMapStyle(MapTheme theme, { String key }) async
test('test getMapStyle', () async {
// TODO
});

View File

@ -0,0 +1,31 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for TimelineApi
void main() {
// final instance = TimelineApi();
group('tests for TimelineApi', () {
//Future<List<AssetResponseDto>> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBucket', () async {
// TODO
});
//Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async
test('test getTimeBuckets', () async {
// TODO
});
});
}

View File

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.99.0+129
version: 1.100.0+130
environment:
sdk: '>=3.0.0 <4.0.0'

View File

@ -1720,286 +1720,6 @@
]
}
},
"/asset/time-bucket": {
"get": {
"operationId": "getTimeBucket",
"parameters": [
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "personId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "timeBucket",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "withPartners",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/time-buckets": {
"get": {
"operationId": "getTimeBuckets",
"parameters": [
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "personId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "withPartners",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TimeBucketResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/upload": {
"post": {
"operationId": "uploadFile",
@ -5628,6 +5348,14 @@
"get": {
"operationId": "getMapStyle",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "theme",
"required": true,
@ -6048,6 +5776,286 @@
]
}
},
"/timeline/bucket": {
"get": {
"operationId": "getTimeBucket",
"parameters": [
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "personId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "timeBucket",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "withPartners",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Timeline"
]
}
},
"/timeline/buckets": {
"get": {
"operationId": "getTimeBuckets",
"parameters": [
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "personId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "withPartners",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TimeBucketResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
},
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Timeline"
]
}
},
"/trash/empty": {
"post": {
"operationId": "emptyTrash",
@ -6538,7 +6546,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.99.0",
"version": "1.100.0",
"contact": {}
},
"tags": [],
@ -8427,12 +8435,17 @@
"type": "array"
},
"title": {
"deprecated": true,
"type": "string"
},
"yearsAgo": {
"type": "integer"
}
},
"required": [
"assets",
"title"
"title",
"yearsAgo"
],
"type": "object"
},

View File

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.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.99.0",
"version": "1.100.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@ -1,6 +1,6 @@
/**
* Immich
* 1.99.0
* 1.100.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@ -273,6 +273,7 @@ export type MapMarkerResponseDto = {
export type MemoryLaneResponseDto = {
assets: AssetResponseDto[];
title: string;
yearsAgo: number;
};
export type UpdateStackParentDto = {
newParentId: string;
@ -283,10 +284,6 @@ export type AssetStatsResponseDto = {
total: number;
videos: number;
};
export type TimeBucketResponseDto = {
count: number;
timeBucket: string;
};
export type CreateAssetDto = {
assetData: Blob;
deviceAssetId: string;
@ -970,6 +967,10 @@ export type CreateTagDto = {
export type UpdateTagDto = {
name?: string;
};
export type TimeBucketResponseDto = {
count: number;
timeBucket: string;
};
export type CreateUserDto = {
email: string;
memoriesEnabled?: boolean;
@ -1455,72 +1456,6 @@ export function getAssetThumbnail({ format, id, key }: {
...opts
}));
}
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
timeBucket: string;
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetResponseDto[];
}>(`/asset/time-bucket${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
order,
personId,
size,
timeBucket,
userId,
withPartners,
withStacked
}))}`, {
...opts
}));
}
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TimeBucketResponseDto[];
}>(`/asset/time-buckets${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
order,
personId,
size,
userId,
withPartners,
withStacked
}))}`, {
...opts
}));
}
export function uploadFile({ key, createAssetDto }: {
key?: string;
createAssetDto: CreateAssetDto;
@ -2487,13 +2422,15 @@ export function getConfigDefaults(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function getMapStyle({ theme }: {
export function getMapStyle({ key, theme }: {
key?: string;
theme: MapTheme;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: object;
}>(`/system-config/map/style.json${QS.query(QS.explode({
key,
theme
}))}`, {
...opts
@ -2594,6 +2531,72 @@ export function tagAssets({ id, assetIdsDto }: {
body: assetIdsDto
})));
}
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
timeBucket: string;
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetResponseDto[];
}>(`/timeline/bucket${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
order,
personId,
size,
timeBucket,
userId,
withPartners,
withStacked
}))}`, {
...opts
}));
}
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TimeBucketResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
order,
personId,
size,
userId,
withPartners,
withStacked
}))}`, {
...opts
}));
}
export function emptyTrash(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/empty", {
...opts,
@ -2788,10 +2791,6 @@ export enum ThumbnailFormat {
Jpeg = "JPEG",
Webp = "WEBP"
}
export enum TimeBucketSize {
Day = "DAY",
Month = "MONTH"
}
export enum EntityType {
Asset = "ASSET",
Album = "ALBUM"
@ -2910,3 +2909,7 @@ export enum MapTheme {
Light = "light",
Dark = "dark"
}
export enum TimeBucketSize {
Day = "DAY",
Month = "MONTH"
}

View File

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.99.0",
"version": "1.100.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.99.0",
"version": "1.100.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@ -2537,9 +2537,9 @@
}
},
"node_modules/@nestjs/common": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.4.tgz",
"integrity": "sha512-HmehujZhUZjf9TN2o0TyzWYNwEgyRYqZZ5qIcF/mCgIUZ4olIKlazna0kGK56FGlCvviHWNKQM5eTuVeTstIgA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.5.tgz",
"integrity": "sha512-XWxbDf2ey/jAyEa3/XpckgfzJZ9j3I05ZkEFx7cAlebFuVKeq5UDDb5Sq9O7hMmbH9xdQj3pYT19SSj01hKeug==",
"dependencies": {
"iterare": "1.2.1",
"tslib": "2.6.2",
@ -2591,9 +2591,9 @@
}
},
"node_modules/@nestjs/core": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.4.tgz",
"integrity": "sha512-rF0yebuHmMj+9/CkbjPWWMvlF5x8j5Biw2DRvbl8R8n2X3OdFBN+06x/9xm3/ZssR5tLoB9tsYspFUb+SvnnwA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz",
"integrity": "sha512-U7SrGD9/Mu4eUtxfZYiGdY38FcksEyJegs4dQZ8B19nnusw0aTocPEy4HVsmx0LLO4sG+fBLLYzCDDr9kFwXAQ==",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/opencollective": "0.3.2",
@ -2659,9 +2659,9 @@
}
},
"node_modules/@nestjs/platform-express": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.4.tgz",
"integrity": "sha512-rzUUUZCGYNs/viT9I6W5izJ1+oYCG0ym/dAn31NmYJW9UchxJdX5PCJqWF8iIbys6JgfbdcapMR5t+L7OZsasQ==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz",
"integrity": "sha512-IhVomwLvdLlv4zCdQK2ROT/nInk1i8m4K48lAUHJV5UVktgVmg0WbQga2/9KywaTjNbx+eWhZXXFii+vtFRAOw==",
"dependencies": {
"body-parser": "1.20.2",
"cors": "2.8.5",
@ -2679,9 +2679,9 @@
}
},
"node_modules/@nestjs/platform-socket.io": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.4.tgz",
"integrity": "sha512-HiL7FbLQBanf8ORxQDpub8wdkRJmXHj8vmExDJ+lD1/E2ChrJbBgRDaKWI7QcSzPKF1uS8VVwz3w0zn3F/EDtA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.5.tgz",
"integrity": "sha512-G2N3sTd9tZ7XQQ7RlrpaQdt1/IBztVHuKg686QmBTLVlRHZ1AMOmXouBk+q5SINT1XURiABa8tQh1Ydx0OEh9w==",
"dependencies": {
"socket.io": "4.7.5",
"tslib": "2.6.2"
@ -2764,9 +2764,9 @@
}
},
"node_modules/@nestjs/testing": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.4.tgz",
"integrity": "sha512-g3NQnRUFBcYF+ySkB7INg5RiV7CNfkP5zwaf3NFo0WjhBrfih9f1jMZ/19blLZ4djN/ngulYks2E3lzROAW8RQ==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.5.tgz",
"integrity": "sha512-j30/lxH0BayeDTigapYtQn/XhMRR7CzlFsm3dHoWViWQv0qT1r2ffe3927BbBLX3N/ZzglE10OAqW06ADZV8dw==",
"dev": true,
"dependencies": {
"tslib": "2.6.2"
@ -2806,9 +2806,9 @@
}
},
"node_modules/@nestjs/websockets": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.4.tgz",
"integrity": "sha512-ZGDY8t1bBYzY2xbOe2QOxYG+D6W1mALSS3VD/rcVW34oaysF4iQQEr4t2ktYLbPAuZlEvwM5EhutqCkBUsDw7Q==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.5.tgz",
"integrity": "sha512-6w383LUBFHoZ0eFODqEHN2NoIRUwbTd37Hc1KqtZZihhFUzscC/0LMAV20o9LdfS/Xjog5ShNTxvOHuzNBnE4A==",
"dependencies": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@ -8748,9 +8748,9 @@
}
},
"node_modules/i18n-iso-countries": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.10.1.tgz",
"integrity": "sha512-9DXmAMkfGcGNE+E/2fE85UUjjkPeT0LHMA8d+kcxXiO+s50W28lxiICel8f8qWZmCNic1cuhN1+nw7ZazMQJFA==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.0.tgz",
"integrity": "sha512-MP2+aAJwvBTuruaMEj+mPEhu4D9rn03GkkbuP8/xkvNzhVwNe2cAg1ivkL5Oj+vwqEwvIcf5C7Q+5Y/UZNLBHw==",
"dependencies": {
"diacritics": "1.3.0"
},
@ -16141,9 +16141,9 @@
}
},
"@nestjs/common": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.4.tgz",
"integrity": "sha512-HmehujZhUZjf9TN2o0TyzWYNwEgyRYqZZ5qIcF/mCgIUZ4olIKlazna0kGK56FGlCvviHWNKQM5eTuVeTstIgA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.5.tgz",
"integrity": "sha512-XWxbDf2ey/jAyEa3/XpckgfzJZ9j3I05ZkEFx7cAlebFuVKeq5UDDb5Sq9O7hMmbH9xdQj3pYT19SSj01hKeug==",
"requires": {
"iterare": "1.2.1",
"tslib": "2.6.2",
@ -16169,9 +16169,9 @@
}
},
"@nestjs/core": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.4.tgz",
"integrity": "sha512-rF0yebuHmMj+9/CkbjPWWMvlF5x8j5Biw2DRvbl8R8n2X3OdFBN+06x/9xm3/ZssR5tLoB9tsYspFUb+SvnnwA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz",
"integrity": "sha512-U7SrGD9/Mu4eUtxfZYiGdY38FcksEyJegs4dQZ8B19nnusw0aTocPEy4HVsmx0LLO4sG+fBLLYzCDDr9kFwXAQ==",
"requires": {
"@nuxtjs/opencollective": "0.3.2",
"fast-safe-stringify": "2.1.1",
@ -16196,9 +16196,9 @@
"requires": {}
},
"@nestjs/platform-express": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.4.tgz",
"integrity": "sha512-rzUUUZCGYNs/viT9I6W5izJ1+oYCG0ym/dAn31NmYJW9UchxJdX5PCJqWF8iIbys6JgfbdcapMR5t+L7OZsasQ==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz",
"integrity": "sha512-IhVomwLvdLlv4zCdQK2ROT/nInk1i8m4K48lAUHJV5UVktgVmg0WbQga2/9KywaTjNbx+eWhZXXFii+vtFRAOw==",
"requires": {
"body-parser": "1.20.2",
"cors": "2.8.5",
@ -16208,9 +16208,9 @@
}
},
"@nestjs/platform-socket.io": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.4.tgz",
"integrity": "sha512-HiL7FbLQBanf8ORxQDpub8wdkRJmXHj8vmExDJ+lD1/E2ChrJbBgRDaKWI7QcSzPKF1uS8VVwz3w0zn3F/EDtA==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.5.tgz",
"integrity": "sha512-G2N3sTd9tZ7XQQ7RlrpaQdt1/IBztVHuKg686QmBTLVlRHZ1AMOmXouBk+q5SINT1XURiABa8tQh1Ydx0OEh9w==",
"requires": {
"socket.io": "4.7.5",
"tslib": "2.6.2"
@ -16260,9 +16260,9 @@
}
},
"@nestjs/testing": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.4.tgz",
"integrity": "sha512-g3NQnRUFBcYF+ySkB7INg5RiV7CNfkP5zwaf3NFo0WjhBrfih9f1jMZ/19blLZ4djN/ngulYks2E3lzROAW8RQ==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.5.tgz",
"integrity": "sha512-j30/lxH0BayeDTigapYtQn/XhMRR7CzlFsm3dHoWViWQv0qT1r2ffe3927BbBLX3N/ZzglE10OAqW06ADZV8dw==",
"dev": true,
"requires": {
"tslib": "2.6.2"
@ -16277,9 +16277,9 @@
}
},
"@nestjs/websockets": {
"version": "10.3.4",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.4.tgz",
"integrity": "sha512-ZGDY8t1bBYzY2xbOe2QOxYG+D6W1mALSS3VD/rcVW34oaysF4iQQEr4t2ktYLbPAuZlEvwM5EhutqCkBUsDw7Q==",
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.5.tgz",
"integrity": "sha512-6w383LUBFHoZ0eFODqEHN2NoIRUwbTd37Hc1KqtZZihhFUzscC/0LMAV20o9LdfS/Xjog5ShNTxvOHuzNBnE4A==",
"requires": {
"iterare": "1.2.1",
"object-hash": "3.0.0",
@ -20746,9 +20746,9 @@
"dev": true
},
"i18n-iso-countries": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.10.1.tgz",
"integrity": "sha512-9DXmAMkfGcGNE+E/2fE85UUjjkPeT0LHMA8d+kcxXiO+s50W28lxiICel8f8qWZmCNic1cuhN1+nw7ZazMQJFA==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.0.tgz",
"integrity": "sha512-MP2+aAJwvBTuruaMEj+mPEhu4D9rn03GkkbuP8/xkvNzhVwNe2cAg1ivkL5Oj+vwqEwvIcf5C7Q+5Y/UZNLBHw==",
"requires": {
"diacritics": "1.3.0"
}

View File

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

View File

@ -1,229 +1,28 @@
import { BullModule } from '@nestjs/bullmq';
import { Module, OnModuleInit, Provider, ValidationPipe } from '@nestjs/common';
import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OpenTelemetryModule } from 'nestjs-otel';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { commands } from 'src/commands';
import { bullConfig, bullQueues, immichAppConfig } from 'src/config';
import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.controller';
import { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
import { AssetController, AssetsController } from 'src/controllers/asset.controller';
import { AuditController } from 'src/controllers/audit.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerInfoController } from 'src/controllers/server-info.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller';
import { TagController } from 'src/controllers/tag.controller';
import { TrashController } from 'src/controllers/trash.controller';
import { UserController } from 'src/controllers/user.controller';
import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config';
import { databaseEntities } from 'src/entities';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { entities } from 'src/entities';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { ActivityService } from 'src/services/activity.service';
import { AlbumService } from 'src/services/album.service';
import { APIKeyService } from 'src/services/api-key.service';
import { repositories } from 'src/repositories';
import { services } from 'src/services';
import { ApiService } from 'src/services/api.service';
import { AssetServiceV1 } from 'src/services/asset-v1.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthService } from 'src/services/auth.service';
import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service';
import { ServerInfoService } from 'src/services/server-info.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { TagService } from 'src/services/tag.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { otelConfig } from 'src/utils/instrumentation';
import { ImmichLogger } from 'src/utils/logger';
const commands = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,
];
const controllers = [
ActivityController,
AssetsController,
AssetControllerV1,
AssetController,
AppController,
AlbumController,
APIKeyController,
AuditController,
AuthController,
DownloadController,
FaceController,
JobController,
LibraryController,
OAuthController,
PartnerController,
SearchController,
ServerInfoController,
SharedLinkController,
SystemConfigController,
TagController,
TrashController,
UserController,
PersonController,
];
const services: Provider[] = [
ApiService,
MicroservicesService,
APIKeyService,
ActivityService,
AlbumService,
AssetService,
AssetServiceV1,
AuditService,
AuthService,
DatabaseService,
DownloadService,
ImmichLogger,
JobService,
LibraryService,
MediaService,
MetadataService,
PartnerService,
PersonService,
SearchService,
ServerInfoService,
SharedLinkService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
TagService,
TrashService,
UserService,
];
const repositories: Provider[] = [
{ provide: IActivityRepository, useClass: ActivityRepository },
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: IKeyRepository, useClass: ApiKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMetricRepository, useClass: MetricRepository },
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository },
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
];
const providers = [ImmichLogger];
const common = [...services, ...providers, ...repositories];
const middleware = [
FileUploadInterceptor,
@ -239,13 +38,13 @@ const imports = [
EventEmitterModule.forRoot(),
OpenTelemetryModule.forRoot(otelConfig),
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature(databaseEntities),
TypeOrmModule.forFeature(entities),
];
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...services, ...repositories, ...middleware],
providers: [...common, ...middleware],
})
export class ApiModule implements OnModuleInit {
constructor(private service: ApiService) {}
@ -257,7 +56,7 @@ export class ApiModule implements OnModuleInit {
@Module({
imports: [...imports],
providers: [...services, ...repositories, SchedulerRegistry],
providers: [...common, SchedulerRegistry],
})
export class MicroservicesModule implements OnModuleInit {
constructor(private service: MicroservicesService) {}
@ -269,7 +68,7 @@ export class MicroservicesModule implements OnModuleInit {
@Module({
imports: [...imports],
providers: [...services, ...repositories, ...commands, SchedulerRegistry],
providers: [...common, ...commands, SchedulerRegistry],
})
export class ImmichAdminModule {}
@ -278,10 +77,10 @@ export class ImmichAdminModule {}
ConfigModule.forRoot(immichAppConfig),
EventEmitterModule.forRoot(),
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature(databaseEntities),
TypeOrmModule.forFeature(entities),
OpenTelemetryModule.forRoot(otelConfig),
],
controllers: [...controllers],
providers: [...services, ...repositories, ...middleware, SchedulerRegistry],
providers: [...common, ...middleware, SchedulerRegistry],
})
export class AppTestModule {}

View File

@ -0,0 +1,14 @@
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
export const commands = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,
];

View File

@ -14,7 +14,6 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, MetadataSearchDto } from 'src/dtos/search.dto';
import { UpdateStackParentDto } from 'src/dtos/stack.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard';
import { Route } from 'src/middleware/file-upload.interceptor';
import { AssetService } from 'src/services/asset.service';
@ -71,18 +70,6 @@ export class AssetController {
return this.service.getStatistics(auth, dto);
}
@Authenticated({ isShared: true })
@Get('time-buckets')
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
return this.service.getTimeBuckets(auth, dto);
}
@Authenticated({ isShared: true })
@Get('time-bucket')
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;
}
@Post('jobs')
@HttpCode(HttpStatus.NO_CONTENT)
runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise<void> {

View File

@ -0,0 +1,50 @@
import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.controller';
import { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
import { AssetController, AssetsController } from 'src/controllers/asset.controller';
import { AuditController } from 'src/controllers/audit.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerInfoController } from 'src/controllers/server-info.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller';
import { TagController } from 'src/controllers/tag.controller';
import { TimelineController } from 'src/controllers/timeline.controller';
import { TrashController } from 'src/controllers/trash.controller';
import { UserController } from 'src/controllers/user.controller';
export const controllers = [
ActivityController,
AssetsController,
AssetControllerV1,
AssetController,
AppController,
AlbumController,
APIKeyController,
AuditController,
AuthController,
DownloadController,
FaceController,
JobController,
LibraryController,
OAuthController,
PartnerController,
SearchController,
ServerInfoController,
SharedLinkController,
SystemConfigController,
TagController,
TimelineController,
TrashController,
UserController,
PersonController,
];

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { MapThemeDto, SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AdminRoute, Authenticated } from 'src/middleware/auth.guard';
import { AdminRoute, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags('System Config')
@ -31,6 +31,7 @@ export class SystemConfigController {
}
@AdminRoute(false)
@SharedLinkRoute()
@Get('map/style.json')
getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme);

View File

@ -0,0 +1,26 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TimelineService } from 'src/services/timeline.service';
@ApiTags('Timeline')
@Controller('timeline')
@Authenticated()
export class TimelineController {
constructor(private service: TimelineService) {}
@Authenticated({ isShared: true })
@Get('buckets')
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
return this.service.getTimeBuckets(auth, dto);
}
@Authenticated({ isShared: true })
@Get('bucket')
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;
}
}

View File

@ -84,7 +84,7 @@ export class AccessCore {
*
* @returns Set<string>
*/
async checkAccess(auth: AuthDto, permission: Permission, ids: Set<string> | string[]) {
async checkAccess(auth: AuthDto, permission: Permission, ids: Set<string> | string[]): Promise<Set<string>> {
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
if (idSet.size === 0) {
return new Set();
@ -97,7 +97,11 @@ export class AccessCore {
return this.checkAccessOther(auth, permission, idSet);
}
private async checkAccessSharedLink(sharedLink: SharedLinkEntity, permission: Permission, ids: Set<string>) {
private async checkAccessSharedLink(
sharedLink: SharedLinkEntity,
permission: Permission,
ids: Set<string>,
): Promise<Set<string>> {
const sharedLinkId = sharedLink.id;
switch (permission) {
@ -140,7 +144,7 @@ export class AccessCore {
}
}
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>): Promise<Set<string>> {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE: {

View File

@ -131,7 +131,12 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
}
export class MemoryLaneResponseDto {
@ApiProperty({ deprecated: true })
title!: string;
@ApiProperty({ type: 'integer' })
yearsAgo!: number;
assets!: AssetResponseDto[];
}

View File

@ -21,7 +21,7 @@ import { TagEntity } from 'src/entities/tag.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity';
export const databaseEntities = [
export const entities = [
ActivityEntity,
AlbumEntity,
APIKeyEntity,

View File

@ -1,4 +1,5 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { IBulkAsset } from 'src/utils/asset.util';
export const IAlbumRepository = 'IAlbumRepository';
@ -23,15 +24,14 @@ export interface AlbumAssets {
assetIds: string[];
}
export interface IAlbumRepository {
export interface IAlbumRepository extends IBulkAsset {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
addAssets(assets: AlbumAssets): Promise<void>;
getAssetIds(albumId: string, assetIds?: string[]): Promise<Set<string>>;
hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(assetId: string): Promise<void>;
removeAssets(albumId: string, assetIds: string[]): Promise<void>;
removeAssetIds(albumId: string, assetIds: string[]): Promise<void>;
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;

View File

@ -0,0 +1,54 @@
-- NOTE: This file is auto generated by ./sql-generator
-- ActivityRepository.search
SELECT
"ActivityEntity"."id" AS "ActivityEntity_id",
"ActivityEntity"."createdAt" AS "ActivityEntity_createdAt",
"ActivityEntity"."updatedAt" AS "ActivityEntity_updatedAt",
"ActivityEntity"."albumId" AS "ActivityEntity_albumId",
"ActivityEntity"."userId" AS "ActivityEntity_userId",
"ActivityEntity"."assetId" AS "ActivityEntity_assetId",
"ActivityEntity"."comment" AS "ActivityEntity_comment",
"ActivityEntity"."isLiked" AS "ActivityEntity_isLiked",
"ActivityEntity__ActivityEntity_user"."id" AS "ActivityEntity__ActivityEntity_user_id",
"ActivityEntity__ActivityEntity_user"."name" AS "ActivityEntity__ActivityEntity_user_name",
"ActivityEntity__ActivityEntity_user"."avatarColor" AS "ActivityEntity__ActivityEntity_user_avatarColor",
"ActivityEntity__ActivityEntity_user"."isAdmin" AS "ActivityEntity__ActivityEntity_user_isAdmin",
"ActivityEntity__ActivityEntity_user"."email" AS "ActivityEntity__ActivityEntity_user_email",
"ActivityEntity__ActivityEntity_user"."storageLabel" AS "ActivityEntity__ActivityEntity_user_storageLabel",
"ActivityEntity__ActivityEntity_user"."oauthId" AS "ActivityEntity__ActivityEntity_user_oauthId",
"ActivityEntity__ActivityEntity_user"."profileImagePath" AS "ActivityEntity__ActivityEntity_user_profileImagePath",
"ActivityEntity__ActivityEntity_user"."shouldChangePassword" AS "ActivityEntity__ActivityEntity_user_shouldChangePassword",
"ActivityEntity__ActivityEntity_user"."createdAt" AS "ActivityEntity__ActivityEntity_user_createdAt",
"ActivityEntity__ActivityEntity_user"."deletedAt" AS "ActivityEntity__ActivityEntity_user_deletedAt",
"ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status",
"ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt",
"ActivityEntity__ActivityEntity_user"."memoriesEnabled" AS "ActivityEntity__ActivityEntity_user_memoriesEnabled",
"ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes",
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes"
FROM
"activity" "ActivityEntity"
LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId"
AND (
"ActivityEntity__ActivityEntity_user"."deletedAt" IS NULL
)
WHERE
(("ActivityEntity"."albumId" = $1))
ORDER BY
"ActivityEntity"."createdAt" ASC
-- ActivityRepository.getStatistics
SELECT
COUNT(DISTINCT ("ActivityEntity"."id")) AS "cnt"
FROM
"activity" "ActivityEntity"
LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId"
AND (
"ActivityEntity__ActivityEntity_user"."deletedAt" IS NULL
)
WHERE
(
("ActivityEntity"."assetId" = $1)
AND ("ActivityEntity"."albumId" = $2)
AND ("ActivityEntity"."isLiked" = $3)
)

View File

@ -590,7 +590,7 @@ DELETE FROM "albums_assets_assets"
WHERE
"albums_assets_assets"."assetsId" = $1
-- AlbumRepository.removeAssets
-- AlbumRepository.removeAssetIds
DELETE FROM "albums_assets_assets"
WHERE
(
@ -646,7 +646,7 @@ WHERE
LIMIT
1
-- AlbumRepository.addAssets
-- AlbumRepository.addAssetIds
INSERT INTO
"albums_assets_assets" ("albumsId", "assetsId")
VALUES

View File

@ -1,5 +1,88 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AssetRepository.getByDayOfYear
SELECT
"entity"."id" AS "entity_id",
"entity"."deviceAssetId" AS "entity_deviceAssetId",
"entity"."ownerId" AS "entity_ownerId",
"entity"."libraryId" AS "entity_libraryId",
"entity"."deviceId" AS "entity_deviceId",
"entity"."type" AS "entity_type",
"entity"."originalPath" AS "entity_originalPath",
"entity"."resizePath" AS "entity_resizePath",
"entity"."webpPath" AS "entity_webpPath",
"entity"."thumbhash" AS "entity_thumbhash",
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
"entity"."createdAt" AS "entity_createdAt",
"entity"."updatedAt" AS "entity_updatedAt",
"entity"."deletedAt" AS "entity_deletedAt",
"entity"."fileCreatedAt" AS "entity_fileCreatedAt",
"entity"."localDateTime" AS "entity_localDateTime",
"entity"."fileModifiedAt" AS "entity_fileModifiedAt",
"entity"."isFavorite" AS "entity_isFavorite",
"entity"."isArchived" AS "entity_isArchived",
"entity"."isExternal" AS "entity_isExternal",
"entity"."isReadOnly" AS "entity_isReadOnly",
"entity"."isOffline" AS "entity_isOffline",
"entity"."checksum" AS "entity_checksum",
"entity"."duration" AS "entity_duration",
"entity"."isVisible" AS "entity_isVisible",
"entity"."livePhotoVideoId" AS "entity_livePhotoVideoId",
"entity"."originalFileName" AS "entity_originalFileName",
"entity"."sidecarPath" AS "entity_sidecarPath",
"entity"."stackId" AS "entity_stackId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps"
FROM
"assets" "entity"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id"
WHERE
(
"entity"."ownerId" IN ($1)
AND "entity"."isVisible" = true
AND "entity"."isArchived" = false
AND "entity"."resizePath" IS NOT NULL
AND EXTRACT(
DAY
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) = $2
AND EXTRACT(
MONTH
FROM
"entity"."localDateTime" AT TIME ZONE 'UTC'
) = $3
)
AND ("entity"."deletedAt" IS NULL)
ORDER BY
"entity"."localDateTime" ASC
-- AssetRepository.getByIds
SELECT
"AssetEntity"."id" AS "AssetEntity_id",
@ -170,6 +253,34 @@ DELETE FROM "assets"
WHERE
"ownerId" = $1
-- AssetRepository.getLibraryAssetPaths
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
FROM
(
SELECT
"AssetEntity"."id" AS "AssetEntity_id",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."isOffline" AS "AssetEntity_isOffline"
FROM
"assets" "AssetEntity"
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
AND (
"AssetEntity__AssetEntity_library"."deletedAt" IS NULL
)
WHERE
(
(
((("AssetEntity__AssetEntity_library"."id" = $1)))
)
)
AND ("AssetEntity"."deletedAt" IS NULL)
) "distinctAlias"
ORDER BY
"AssetEntity_id" ASC
LIMIT
2
-- AssetRepository.getByLibraryIdAndOriginalPath
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"

View File

@ -1 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator

View File

@ -0,0 +1,66 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MetadataRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "exif_country",
"exif"."assetId" AS "exif_assetId"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."country" IS NOT NULL
-- MetadataRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "exif_state",
"exif"."assetId" AS "exif_assetId"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."state" IS NOT NULL
AND "exif"."country" = $2
-- MetadataRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "exif_city",
"exif"."assetId" AS "exif_assetId"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."city" IS NOT NULL
AND "exif"."country" = $2
AND "exif"."state" = $3
-- MetadataRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "exif_make",
"exif"."assetId" AS "exif_assetId"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."make" IS NOT NULL
AND "exif"."model" = $2
-- MetadataRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "exif_model",
"exif"."assetId" AS "exif_assetId"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."model" IS NOT NULL
AND "exif"."make" = $2

View File

@ -1 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator

View File

@ -278,7 +278,7 @@ WITH RECURSIVE
exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE
"ownerId" = ANY ('$1'::uuid [])
"ownerId" = ANY ($1::uuid [])
AND "isVisible" = $2
AND "isArchived" = $3
AND type = $4
@ -302,7 +302,7 @@ WITH RECURSIVE
INNER JOIN assets ON exif."assetId" = assets.id
WHERE
city > c.city
AND "ownerId" = ANY ('$1'::uuid [])
AND "ownerId" = ANY ($1::uuid [])
AND "isVisible" = $2
AND "isArchived" = $3
AND type = $4

View File

@ -1 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator

View File

@ -1 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator

View File

@ -5,13 +5,7 @@ import { dataSource } from 'src/database.config';
import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import {
AlbumAsset,
AlbumAssetCount,
AlbumAssets,
AlbumInfoOptions,
IAlbumRepository,
} from 'src/interfaces/album.interface';
import { AlbumAsset, AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { setUnion } from 'src/utils/set';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
@ -203,7 +197,7 @@ export class AlbumRepository implements IAlbumRepository {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async removeAssets(albumId: string, assetIds: string[]): Promise<void> {
async removeAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.dataSource
.createQueryBuilder()
.delete()
@ -260,8 +254,8 @@ export class AlbumRepository implements IAlbumRepository {
});
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] })
async addAssets({ albumId, assetIds }: AlbumAssets): Promise<void> {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.dataSource
.createQueryBuilder()
.insert()

View File

@ -75,7 +75,7 @@ export class AssetRepository implements IAssetRepository {
return this.repository.save(asset);
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
@GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository
.createQueryBuilder('entity')
@ -93,7 +93,7 @@ export class AssetRepository implements IAssetRepository {
},
)
.leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.orderBy('entity.localDateTime', 'DESC')
.orderBy('entity.localDateTime', 'ASC')
.getMany();
}
@ -159,7 +159,7 @@ export class AssetRepository implements IAssetRepository {
return this.getAll(pagination, { ...options, userIds: [userId] });
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
return paginate(this.repository, pagination, {
select: { id: true, originalPath: true, isOffline: true },

View File

@ -0,0 +1,90 @@
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository';
export const repositories = [
{ provide: IActivityRepository, useClass: ActivityRepository },
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: IKeyRepository, useClass: ApiKeyRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMetricRepository, useClass: MetricRepository },
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository },
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
];

View File

@ -5,7 +5,7 @@ import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { IsNull, Not } from 'typeorm';
import { EntityNotFoundError, IsNull, Not } from 'typeorm';
import { Repository } from 'typeorm/repository/Repository.js';
@Instrumentation()
@ -139,6 +139,10 @@ export class LibraryRepository implements ILibraryRepository {
.where('libraries.id = :id', { id })
.getRawOne();
if (!stats) {
throw new EntityNotFoundError(LibraryEntity, { where: { id } });
}
return {
photos: Number(stats.photos),
videos: Number(stats.videos),

View File

@ -107,6 +107,7 @@ export class PersonRepository implements IPersonRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> {
// TODO return null instead of find or fail
return this.assetFaceRepository.findOneOrFail({
where: { id },
relations: {

View File

@ -225,7 +225,7 @@ export class SearchRepository implements ISearchRepository {
.getMany();
}
@GenerateSql({ params: [[DummyValue.UUID, DummyValue.UUID]] })
@GenerateSql({ params: [[DummyValue.UUID]] })
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
const parameters = [userIds, true, false, AssetType.IMAGE];
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
@ -315,7 +315,7 @@ WITH RECURSIVE cte AS (
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE "ownerId" = ANY('$1'::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
WHERE "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
)
@ -328,7 +328,7 @@ WITH RECURSIVE cte AS (
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE city > c.city AND "ownerId" = ANY('$1'::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
WHERE city > c.city AND "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
) l

View File

@ -518,10 +518,7 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
});
it('should not set the thumbnail if the album has one already', async () => {
@ -539,7 +536,7 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id',
});
expect(albumMock.addAssets).toHaveBeenCalled();
expect(albumMock.addAssetIds).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
@ -561,10 +558,7 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
});
it('should allow a shared link user to add assets', async () => {
@ -586,10 +580,7 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1',
});
expect(albumMock.addAssets).toHaveBeenCalledWith({
albumId: 'album-123',
assetIds: ['asset-1', 'asset-2', 'asset-3'],
});
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id,
@ -665,23 +656,23 @@ describe(AlbumService.name, () => {
describe('removeAssets', () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123']));
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id']));
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
expect(albumMock.removeAssets).toHaveBeenCalledWith('album-123', ['asset-id']);
expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']);
});
it('should skip assets not in the album', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
albumMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
@ -693,7 +684,7 @@ describe(AlbumService.name, () => {
it('should skip assets without user permission to remove', async () => {
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{
@ -707,10 +698,10 @@ describe(AlbumService.name, () => {
});
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123']));
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id']));
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },

View File

@ -12,7 +12,7 @@ import {
mapAlbumWithAssets,
mapAlbumWithoutAssets,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
@ -21,13 +21,13 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { setUnion } from 'src/utils/set';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
export class AlbumService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@ -164,37 +164,20 @@ export class AlbumService {
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(auth, Permission.ALBUM_READ, id);
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
const notPresentAssetIds = dto.ids.filter((id) => !existingAssetIds.has(id));
const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds);
const results = await addAssets(
auth,
{ accessRepository: this.accessRepository, repository: this.albumRepository },
{ id, assetIds: dto.ids },
);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.ids) {
const hasAsset = existingAssetIds.has(assetId);
if (hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id: assetId, success: true });
}
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds });
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) {
await this.albumRepository.update({
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0],
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
}
@ -206,31 +189,14 @@ export class AlbumService {
await this.access.requirePermission(auth, Permission.ALBUM_READ, id);
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
const canRemove = await this.access.checkAccess(auth, Permission.ALBUM_REMOVE_ASSET, existingAssetIds);
const canShare = await this.access.checkAccess(auth, Permission.ASSET_SHARE, existingAssetIds);
const allowedAssetIds = setUnion(canRemove, canShare);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.ids) {
const hasAsset = existingAssetIds.has(assetId);
if (!hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id: assetId, success: true });
}
const results = await removeAssets(
auth,
{ accessRepository: this.accessRepository, repository: this.albumRepository },
{ id, assetIds: dto.ids, permissions: [Permission.ASSET_SHARE, Permission.ALBUM_REMOVE_ASSET] },
);
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) {
await this.albumRepository.removeAssets(id, removedIds);
await this.albumRepository.update({ id, updatedAt: new Date() });
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();

View File

@ -4,7 +4,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto';
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { AssetStats, IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobItem, JobName } from 'src/interfaces/job.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@ -307,13 +307,17 @@ describe(AssetService.name, () => {
jest.useRealTimers();
});
it('should set the title correctly', async () => {
it('should group the assets correctly', async () => {
const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) };
const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) };
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
partnerMock.getAll.mockResolvedValue([]);
assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3]);
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
{ title: '1 year since...', assets: [mapAsset(assetStub.image)] },
{ title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
{ yearsAgo: 1, title: '1 year since...', assets: [mapAsset(image1), mapAsset(image2)] },
{ yearsAgo: 9, title: '9 years since...', assets: [mapAsset(image3)] },
]);
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
@ -321,6 +325,7 @@ describe(AssetService.name, () => {
it('should get memories with partners with inTimeline enabled', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]);
assetMock.getByDayOfYear.mockResolvedValue([]);
await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 });
@ -330,129 +335,6 @@ describe(AssetService.name, () => {
});
});
describe('getTimeBuckets', () => {
it("should return buckets if userId and albumId aren't set", async () => {
assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
await expect(
sut.getTimeBuckets(authStub.admin, {
size: TimeBucketSize.DAY,
}),
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id] });
});
});
describe('getTimeBucket', () => {
it('should return the assets for a album time bucket if user has album.read', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
expect(assetMock.getTimeBucket).toBeCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
albumId: 'album-id',
});
});
it('should return the assets for a archive time bucket if user has archive.read', async () => {
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toBeCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userIds: [authStub.admin.user.id],
});
});
it('should return the assets for a library time bucket if user has library.read', async () => {
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toBeCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userIds: [authStub.admin.user.id],
});
});
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrowError(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: undefined,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrowError(BadRequestException);
});
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrowError(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: false,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrowError(BadRequestException);
});
it('should throw an error if withParners is true and isTrash is true', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isTrashed: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrowError(BadRequestException);
});
});
describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => {
assetMock.getStatistics.mockResolvedValue(stats);

View File

@ -25,12 +25,11 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto';
import { UpdateStackParentDto } from 'src/dtos/stack.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import {
IAssetDeletionJob,
@ -174,86 +173,25 @@ export class AssetService {
userIds.push(...partnersIds);
const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
return _.chain(assets)
.filter((asset) => asset.localDateTime.getFullYear() < currentYear)
.map((asset) => {
const years = currentYear - asset.localDateTime.getFullYear();
return {
title: `${years} year${years > 1 ? 's' : ''} since...`,
asset: mapAsset(asset, { auth }),
};
})
.groupBy((asset) => asset.title)
.map((items, title) => ({ title, assets: items.map(({ asset }) => asset) }))
.value();
}
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await this.access.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]);
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await this.access.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]);
if (dto.isArchived !== false) {
await this.access.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]);
const groups: Record<number, AssetEntity[]> = {};
for (const asset of assets) {
const yearsAgo = currentYear - asset.localDateTime.getFullYear();
if (!groups[yearsAgo]) {
groups[yearsAgo] = [];
}
groups[yearsAgo].push(asset);
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
);
}
}
}
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
return this.assetRepository.getTimeBuckets(timeBucketOptions);
}
async getTimeBucket(
auth: AuthDto,
dto: TimeBucketAssetDto,
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
return !auth.sharedLink || auth.sharedLink?.showExif
? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
}
async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
const { userId, ...options } = dto;
let userIds: string[] | undefined = undefined;
if (userId) {
userIds = [userId];
if (dto.withPartners) {
const partners = await this.partnerRepository.getAll(auth.user.id);
const partnersIds = partners
.filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline)
.map((partner) => partner.sharedById);
userIds.push(...partnersIds);
}
}
return { ...options, userIds };
return Object.keys(groups)
.map(Number)
.sort()
.filter((yearsAgo) => yearsAgo > 0)
.map((yearsAgo) => ({
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} since...`,
assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })),
}));
}
async getStatistics(auth: AuthDto, dto: AssetStatsDto) {

View File

@ -0,0 +1,59 @@
import { ActivityService } from 'src/services/activity.service';
import { AlbumService } from 'src/services/album.service';
import { APIKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service';
import { AssetServiceV1 } from 'src/services/asset-v1.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthService } from 'src/services/auth.service';
import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service';
import { ServerInfoService } from 'src/services/server-info.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
export const services = [
ApiService,
MicroservicesService,
APIKeyService,
ActivityService,
AlbumService,
AssetService,
AssetServiceV1,
AuditService,
AuthService,
DatabaseService,
DownloadService,
JobService,
LibraryService,
MediaService,
MetadataService,
PartnerService,
PersonService,
SearchService,
ServerInfoService,
SharedLinkService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
TagService,
TimelineService,
TrashService,
UserService,
];

View File

@ -549,17 +549,17 @@ describe(LibraryService.name, () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
assetPath: assetStub.hasFileExtension.originalPath,
force: false,
};
storageMock.stat.mockResolvedValue({
size: 100,
mtime: assetStub.image.fileModifiedAt,
mtime: assetStub.hasFileExtension.fileModifiedAt,
ctime: new Date('2023-01-01'),
} as Stats);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
@ -596,6 +596,26 @@ describe(LibraryService.name, () => {
});
});
it('should import an asset that is missing a file extension', async () => {
// This tests for the case where the file extension is missing from the asset path.
// This happened in previous versions of Immich
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: assetStub.missingFileExtension.originalPath,
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith(
[assetStub.missingFileExtension.id],
expect.objectContaining({ originalFileName: 'photo.jpg' }),
);
});
it('should set a missing asset to offline', async () => {
storageMock.stat.mockRejectedValue(new Error('Path not found'));
@ -666,19 +686,20 @@ describe(LibraryService.name, () => {
it('should refresh an existing asset if forced', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: assetStub.image.id,
ownerId: assetStub.image.ownerId,
assetPath: '/data/user1/photo.jpg',
ownerId: assetStub.hasFileExtension.ownerId,
assetPath: assetStub.hasFileExtension.originalPath,
force: true,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
assetMock.create.mockResolvedValue(assetStub.hasFileExtension);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], {
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], {
fileCreatedAt: new Date('2023-01-01'),
fileModifiedAt: new Date('2023-01-01'),
originalFileName: assetStub.hasFileExtension.originalFileName,
});
});

View File

@ -436,6 +436,8 @@ export class LibraryService extends EventEmitter {
doRefresh = true;
}
const originalFileName = parse(assetPath).base;
if (!existingAssetEntity) {
// This asset is new to us, read it from disk
this.logger.debug(`Importing new asset: ${assetPath}`);
@ -446,6 +448,12 @@ export class LibraryService extends EventEmitter {
`File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`,
);
doRefresh = true;
} else if (existingAssetEntity.originalFileName !== originalFileName) {
// TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users
this.logger.debug(
`Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`,
);
doRefresh = true;
} else if (!job.force && stats && !existingAssetEntity.isOffline) {
// Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing
this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`);
@ -504,7 +512,7 @@ export class LibraryService extends EventEmitter {
fileModifiedAt: stats.mtime,
localDateTime: stats.mtime,
type: assetType,
originalFileName: parse(assetPath).base,
originalFileName,
sidecarPath,
isReadOnly: true,
isExternal: true,
@ -515,6 +523,7 @@ export class LibraryService extends EventEmitter {
await this.assetRepository.updateAll([existingAssetEntity.id], {
fileCreatedAt: stats.mtime,
fileModifiedAt: stats.mtime,
originalFileName,
});
} else {
// Not importing and not refreshing, do nothing

View File

@ -0,0 +1,149 @@
import { BadRequestException } from '@nestjs/common';
import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
describe(TimelineService.name, () => {
let sut: TimelineService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
partnerMock = newPartnerRepositoryMock();
sut = new TimelineService(accessMock, assetMock, partnerMock);
});
describe('getTimeBuckets', () => {
it("should return buckets if userId and albumId aren't set", async () => {
assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
await expect(
sut.getTimeBuckets(authStub.admin, {
size: TimeBucketSize.DAY,
}),
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({
size: TimeBucketSize.DAY,
userIds: [authStub.admin.user.id],
});
});
});
describe('getTimeBucket', () => {
it('should return the assets for a album time bucket if user has album.read', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
albumId: 'album-id',
});
});
it('should return the assets for a archive time bucket if user has archive.read', async () => {
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userIds: [authStub.admin.user.id],
});
});
it('should return the assets for a library time bucket if user has library.read', async () => {
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userIds: [authStub.admin.user.id],
});
});
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrow(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: undefined,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrow(BadRequestException);
});
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrow(BadRequestException);
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isFavorite: false,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrow(BadRequestException);
});
it('should throw an error if withParners is true and isTrash is true', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isTrashed: true,
withPartners: true,
userId: authStub.admin.user.id,
}),
).rejects.toThrow(BadRequestException);
});
});
});

View File

@ -0,0 +1,86 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
export class TimelineService {
private accessCore: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private repository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {
this.accessCore = AccessCore.create(accessRepository);
}
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
return this.repository.getTimeBuckets(timeBucketOptions);
}
async getTimeBucket(
auth: AuthDto,
dto: TimeBucketAssetDto,
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions);
return !auth.sharedLink || auth.sharedLink?.showExif
? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
}
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
const { userId, ...options } = dto;
let userIds: string[] | undefined = undefined;
if (userId) {
userIds = [userId];
if (dto.withPartners) {
const partners = await this.partnerRepository.getAll(auth.user.id);
const partnersIds = partners
.filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline)
.map((partner) => partner.sharedById);
userIds.push(...partnersIds);
}
}
return { ...options, userIds };
}
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]);
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]);
if (dto.isArchived !== false) {
await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]);
}
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
);
}
}
}
}

View File

@ -0,0 +1,91 @@
import { AccessCore, Permission } from 'src/cores/access.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { setDifference, setUnion } from 'src/utils/set';
export interface IBulkAsset {
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
removeAssetIds: (id: string, assetIds: string[]) => Promise<void>;
}
export const addAssets = async (
auth: AuthDto,
repositories: { accessRepository: IAccessRepository; repository: IBulkAsset },
dto: { id: string; assetIds: string[] },
) => {
const { accessRepository, repository } = repositories;
const access = AccessCore.create(accessRepository);
const existingAssetIds = await repository.getAssetIds(dto.id, dto.assetIds);
const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id));
const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = existingAssetIds.has(assetId);
if (hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id: assetId, success: true });
}
const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
if (newAssetIds.length > 0) {
await repository.addAssetIds(dto.id, newAssetIds);
}
return results;
};
export const removeAssets = async (
auth: AuthDto,
repositories: { accessRepository: IAccessRepository; repository: IBulkAsset },
dto: { id: string; assetIds: string[]; permissions: Permission[] },
) => {
const { accessRepository, repository } = repositories;
const access = AccessCore.create(accessRepository);
const existingAssetIds = await repository.getAssetIds(dto.id, dto.assetIds);
let allowedAssetIds = new Set<string>();
let remainingAssetIds = existingAssetIds;
for (const permission of dto.permissions) {
const newAssetIds = await access.checkAccess(auth, permission, setDifference(remainingAssetIds, allowedAssetIds));
remainingAssetIds = setDifference(remainingAssetIds, newAssetIds);
allowedAssetIds = setUnion(allowedAssetIds, newAssetIds);
}
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = existingAssetIds.has(assetId);
if (!hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = allowedAssetIds.has(assetId);
if (!hasAccess) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id: assetId, success: true });
}
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) {
await repository.removeAssetIds(dto.id, removedIds);
}
return results;
};

View File

@ -1,31 +1,21 @@
#!/usr/bin/env node
import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OpenTelemetryModule } from 'nestjs-otel';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { format } from 'sql-formatter';
import { databaseConfig } from 'src/database.config';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
import { databaseEntities } from 'src/entities';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { entities } from 'src/entities';
import { repositories } from 'src/repositories';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { AuthService } from 'src/services/auth.service';
import { otelConfig } from 'src/utils/instrumentation';
import { Logger } from 'typeorm';
export class SqlLogger implements Logger {
@ -52,26 +42,9 @@ export class SqlLogger implements Logger {
}
const reflector = new Reflector();
const repositories = [
AccessRepository,
AlbumRepository,
ApiKeyRepository,
AssetRepository,
AuditRepository,
LibraryRepository,
MoveRepository,
PartnerRepository,
PersonRepository,
SharedLinkRepository,
SearchRepository,
SystemConfigRepository,
SystemMetadataRepository,
TagRepository,
UserTokenRepository,
UserRepository,
];
type Repository = (typeof repositories)[0];
type Repository = (typeof repositories)[0]['useClass'];
type Provider = { provide: any; useClass: Repository };
type SqlGeneratorOptions = { targetDir: string };
class SqlGenerator {
@ -84,8 +57,8 @@ class SqlGenerator {
async run() {
try {
await this.setup();
for (const Repository of repositories) {
await this.process(Repository);
for (const repository of repositories) {
await this.process(repository);
}
await this.write();
this.stats();
@ -102,25 +75,27 @@ class SqlGenerator {
imports: [
TypeOrmModule.forRoot({
...databaseConfig,
entities: databaseEntities,
entities,
logging: ['query'],
logger: this.sqlLogger,
}),
TypeOrmModule.forFeature(databaseEntities),
TypeOrmModule.forFeature(entities),
EventEmitterModule.forRoot(),
OpenTelemetryModule.forRoot(otelConfig),
],
providers: [{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, ...repositories],
providers: [...repositories, AuthService, SchedulerRegistry],
}).compile();
this.app = await moduleFixture.createNestApplication().init();
}
async process(Repository: Repository) {
async process({ provide: token, useClass: Repository }: Provider) {
if (!this.app) {
throw new Error('Not initialized');
}
const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`];
const instance = this.app.get<Repository>(Repository);
const instance = this.app.get<Repository>(token);
// normal repositories
data.push(...(await this.runTargets(instance, `${Repository.name}`)));
@ -180,6 +155,10 @@ class SqlGenerator {
private async write() {
for (const [repoName, data] of Object.entries(this.results)) {
// only contains the header
if (data.length === 1) {
continue;
}
const filename = repoName.replaceAll(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', '');
const file = join(this.options.targetDir, `${filename}.sql`);
await writeFile(file, data.join('\n\n') + '\n');

View File

@ -639,4 +639,82 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
missingFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/user1/photo.jpg',
resizePath: '/uploads/user-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: true,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'photo',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as ExifEntity,
}),
hasFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/user1/photo.jpg',
resizePath: '/uploads/user-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
isExternal: true,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'photo.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as ExifEntity,
}),
};

View File

@ -14,9 +14,9 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
softDeleteAll: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
addAssets: jest.fn(),
addAssetIds: jest.fn(),
removeAsset: jest.fn(),
removeAssets: jest.fn(),
removeAssetIds: jest.fn(),
getAssetIds: jest.fn(),
hasAsset: jest.fn(),
create: jest.fn(),

16
web/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.99.0",
"version": "1.100.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.99.0",
"version": "1.100.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
@ -63,14 +63,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.99.0",
"version": "1.100.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.11.30",
"typescript": "^5.4.3"
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -8671,9 +8671,9 @@
}
},
"node_modules/vite": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz",
"integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.3.tgz",
"integrity": "sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw==",
"dev": true,
"dependencies": {
"esbuild": "^0.20.1",

View File

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

View File

@ -61,7 +61,7 @@
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
<div>
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
</div>
</div>

View File

@ -97,6 +97,7 @@
{#if isOwned}
<div>
<CircleIconButton
title="Options"
on:click={(event) => showContextMenu(event, user)}
icon={mdiDotsVertical}
backgroundColor="transparent"

View File

@ -305,7 +305,13 @@
</div>
{:else if message}
<div class="flex items-end w-fit ml-0">
<CircleIconButton size="15" icon={mdiSend} iconColor={'dark'} hoverColor={'rgb(173,203,250)'} />
<CircleIconButton
title="Send message"
size="15"
icon={mdiSend}
iconColor={'dark'}
hoverColor={'rgb(173,203,250)'}
/>
</div>
{/if}
</form>

View File

@ -2,6 +2,7 @@
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
const dispatch = createEventDispatcher<{
album: void;
@ -16,7 +17,7 @@
// It is used to highlight the search query in the album name
$: {
let { albumName } = album;
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase());
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length;
albumNameArray = [
albumName.slice(0, findIndex),

View File

@ -102,7 +102,7 @@
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
>
<div class="text-white">
<CircleIconButton isOpacity={true} icon={mdiArrowLeft} on:click={() => dispatch('back')} />
<CircleIconButton isOpacity={true} icon={mdiArrowLeft} title="Go back" on:click={() => dispatch('back')} />
</div>
<div class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white">
{#if showShareButton}
@ -182,7 +182,12 @@
{#if !asset.isReadOnly || !asset.isExternal}
<CircleIconButton isOpacity={true} icon={mdiDeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
{/if}
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<div
use:clickOutside={{
onOutclick: () => (isShowAssetOptions = false),
onEscape: () => (isShowAssetOptions = false),
}}
>
<CircleIconButton isOpacity={true} icon={mdiDotsVertical} on:click={showOptionsMenu} title="More" />
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">

View File

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { AssetAction, ProjectionType } from '$lib/constants';
import { updateNumberOfComments } from '$lib/stores/activity.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore } from '$lib/stores/assets.store';
@ -11,7 +10,7 @@
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, isSharedLink, handlePromiseError } from '$lib/utils';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { shortcuts } from '$lib/utils/shortcut';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
@ -20,7 +19,6 @@
AssetTypeEnum,
ReactionType,
createActivity,
createAlbum,
deleteActivity,
deleteAssets,
getActivities,
@ -52,6 +50,7 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-viewer.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@ -287,7 +286,15 @@
$restartSlideshowProgress = true;
};
const navigateAsset = async (order: 'previous' | 'next', e?: Event) => {
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
}
}
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
return (order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next()) || navigateAssetRandom();
}
@ -390,8 +397,7 @@
const handleAddToNewAlbum = async (albumName: string) => {
isShowAlbumPicker = false;
const album = await createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } });
await goto(`${AppRoute.ALBUMS}/${album.id}`);
await addAssetsToNewAlbum(albumName, [asset.id]);
};
const handleAddToAlbum = async (album: AlbumResponseDto) => {
@ -448,12 +454,6 @@
}
};
const handleVideoEnded = async () => {
if ($slideshowState === SlideshowState.PlaySlideshow) {
await navigateAsset('next');
}
};
const handlePlaySlideshow = async () => {
try {
await assetViewerHtmlElement.requestFullscreen();
@ -466,6 +466,7 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
} catch (error) {
@ -517,239 +518,255 @@
<svelte:document bind:fullscreenElement />
<section
id="immich-asset-viewer"
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showSlideshow={!!assetStore}
hasStackChildren={$stackAssetsStore.length > 0}
showShareButton={shouldShowShareModal}
on:back={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset)}
on:delete={() => trashOrDelete()}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:addToSharedAlbum={() => openAlbumPicker(true)}
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
on:showShareModal={() => (isShowShareModal = true)}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
{#if $slideshowState != SlideshowState.None}
<div class="z-[1000] absolute w-full flex">
<SlideshowBar
{isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen()}
onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')}
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
<FocusTrap>
<section
id="immich-asset-viewer"
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showSlideshow={!!assetStore}
hasStackChildren={$stackAssetsStore.length > 0}
showShareButton={shouldShowShareModal}
on:back={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset)}
on:delete={() => trashOrDelete()}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:addToSharedAlbum={() => openAlbumPicker(true)}
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
on:showShareModal={() => (isShowShareModal = true)}
/>
</div>
{/if}
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer asset={previewStackedAsset} {preloadAssets} on:close={closeViewer} haveFadeTransition={false} />
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
on:close={closeViewer}
on:onVideoEnded={handleVideoEnded}
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if !asset.resized}
<div class="flex h-full w-full justify-center">
<div
class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
>
<Icon path={mdiImageBrokenVariant} size="25%" />
</div>
</div>
{:else if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<PanoramaViewer {asset} />
{:else}
<PhotoViewer {asset} {preloadAssets} on:close={closeViewer} />
{/if}
{:else}
<VideoViewer
assetId={asset.id}
on:close={closeViewer}
on:onVideoEnded={handleVideoEnded}
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked}
{numberOfComments}
{isShowActivity}
on:favorite={handleFavorite}
on:openActivityTab={handleOpenActivity}
/>
</div>
{/if}
{/key}
{/if}
{#if $stackAssetsStore.length > 0 && withStacked}
<div
id="stack-slideshow"
class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
>
<div class="relative w-full whitespace-nowrap transition-all">
{#each $stackAssetsStore as stackedAsset, index (stackedAsset.id)}
<div
class="{stackedAsset.id == asset.id
? '-translate-y-[1px]'
: '-translate-y-0'} inline-block px-1 transition-transform"
>
<Thumbnail
class="{stackedAsset.id == asset.id
? 'bg-transparent border-2 border-white'
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
asset={stackedAsset}
onClick={() => {
asset = stackedAsset;
preloadAssets = index + 1 >= $stackAssetsStore.length ? [] : [$stackAssetsStore[index + 1]];
}}
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
readonly
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
showStackedIcon={false}
/>
{#if stackedAsset.id == asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" />
</div>
{/if}
</div>
{/each}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
</div>
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
<!-- Asset Viewer -->
<div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
{#if $slideshowState != SlideshowState.None}
<div class="z-[1000] absolute w-full flex">
<SlideshowBar
{isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen()}
onPrevious={() => navigateAsset('previous')}
onNext={() => navigateAsset('next')}
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
/>
</div>
{/if}
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer
asset={previewStackedAsset}
{preloadAssets}
on:close={closeViewer}
haveFadeTransition={false}
/>
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if !asset.resized}
<div class="flex h-full w-full justify-center">
<div
class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
>
<Icon path={mdiImageBrokenVariant} size="25%" />
</div>
</div>
{:else if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<PanoramaViewer {asset} />
{:else}
<PhotoViewer {asset} {preloadAssets} on:close={closeViewer} />
{/if}
{:else}
<VideoViewer
assetId={asset.id}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked}
{numberOfComments}
{isShowActivity}
on:favorite={handleFavorite}
on:openActivityTab={handleOpenActivity}
/>
</div>
{/if}
{/key}
{/if}
{#if $stackAssetsStore.length > 0 && withStacked}
<div
id="stack-slideshow"
class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
>
<div class="relative w-full whitespace-nowrap transition-all">
{#each $stackAssetsStore as stackedAsset, index (stackedAsset.id)}
<div
class="{stackedAsset.id == asset.id
? '-translate-y-[1px]'
: '-translate-y-0'} inline-block px-1 transition-transform"
>
<Thumbnail
class="{stackedAsset.id == asset.id
? 'bg-transparent border-2 border-white'
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
asset={stackedAsset}
onClick={() => {
asset = stackedAsset;
preloadAssets = index + 1 >= $stackAssetsStore.length ? [] : [$stackAssetsStore[index + 1]];
}}
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
readonly
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
showStackedIcon={false}
/>
{#if stackedAsset.id == asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" />
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
{#if $slideshowState === SlideshowState.None && $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<DetailPanel
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<DetailPanel
{asset}
currentAlbum={album}
albums={appearsInAlbums}
on:close={() => ($isShowDetail = false)}
on:closeViewer={handleCloseViewer}
/>
</div>
{/if}
{#if isShared && album && isShowActivity && $user}
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<ActivityViewer
user={$user}
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
{isLiked}
bind:reactions
on:addComment={handleAddComment}
on:deleteComment={handleRemoveComment}
on:deleteLike={() => (isLiked = null)}
on:close={() => (isShowActivity = false)}
/>
</div>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
on:close={() => (isShowAlbumPicker = false)}
on:escape={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={1}
on:cancel={() => (isShowDeleteConfirmation = false)}
on:escape={() => (isShowDeleteConfirmation = false)}
on:confirm={() => deleteAsset()}
/>
{/if}
{#if isShowProfileImageCrop}
<ProfileImageCropper
{asset}
currentAlbum={album}
albums={appearsInAlbums}
on:close={() => ($isShowDetail = false)}
on:closeViewer={handleCloseViewer}
on:close={() => (isShowProfileImageCrop = false)}
on:escape={() => (isShowProfileImageCrop = false)}
/>
</div>
{/if}
{/if}
{#if isShared && album && isShowActivity && $user}
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<ActivityViewer
user={$user}
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumId={album.id}
assetId={asset.id}
{isLiked}
bind:reactions
on:addComment={handleAddComment}
on:deleteComment={handleRemoveComment}
on:deleteLike={() => (isLiked = null)}
on:close={() => (isShowActivity = false)}
{#if isShowShareModal}
<CreateSharedLinkModal
assetIds={[asset.id]}
on:close={() => (isShowShareModal = false)}
on:escape={() => (isShowShareModal = false)}
/>
</div>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
on:close={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={1}
on:cancel={() => (isShowDeleteConfirmation = false)}
on:escape={() => (isShowDeleteConfirmation = false)}
on:confirm={() => deleteAsset()}
/>
{/if}
{#if isShowProfileImageCrop}
<ProfileImageCropper {asset} on:close={() => (isShowProfileImageCrop = false)} />
{/if}
{#if isShowShareModal}
<CreateSharedLinkModal assetIds={[asset.id]} on:close={() => (isShowShareModal = false)} />
{/if}
</section>
{/if}
</section>
</FocusTrap>
<style>
#immich-asset-viewer {

View File

@ -39,7 +39,13 @@
</div>
</div>
<div class="absolute right-2">
<CircleIconButton on:click={() => abort(downloadKey, download)} size="20" icon={mdiClose} forceDark />
<CircleIconButton
title="Close"
on:click={() => abort(downloadKey, download)}
size="20"
icon={mdiClose}
forceDark
/>
</div>
</div>
{/each}

View File

@ -2,11 +2,11 @@
import Icon from '$lib/components/elements/icon.svelte';
export let icon: string;
export let title: string;
export let backgroundColor = '';
export let hoverColor = '#e2e7e9';
export let padding = '3';
export let size = '24';
export let title = '';
export let isOpacity = false;
export let forceDark = false;
export let hideMobile = false;
@ -27,7 +27,7 @@
{hideMobile && 'hidden sm:flex'}"
on:click
>
<Icon path={icon} {size} color={iconColor} />
<Icon path={icon} {size} ariaLabel={title} color={iconColor} />
<slot />
</button>

View File

@ -23,7 +23,7 @@
? 'rounded-2xl'
: 'rounded-t-lg'} bg-gray-200 p-2 dark:bg-immich-dark-gray gap-2 place-items-center h-full"
>
<button on:click={() => dispatch('search', { force: true })}>
<button type="button" on:click={() => dispatch('search', { force: true })}>
<div class="w-fit">
<Icon path={mdiMagnify} size="24" />
</div>

View File

@ -135,7 +135,12 @@
</div>
{#if selectedPeople.length === 1}
<div class="absolute bottom-2">
<CircleIconButton icon={mdiSwapHorizontal} size="24" on:click={handleSwapPeople} />
<CircleIconButton
title="Swap merge direction"
icon={mdiSwapHorizontal}
size="24"
on:click={handleSwapPeople}
/>
</div>
{/if}
</div>

View File

@ -40,7 +40,7 @@
Merge People - {title}
</h1>
<div class="p-2">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
</div>
</div>
@ -57,6 +57,7 @@
</div>
<div class="mx-0.5 flex md:mx-2">
<CircleIconButton
title="Swap merge direction"
icon={mdiMerge}
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
/>

View File

@ -29,7 +29,7 @@
class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex items-center">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
<div class="flex gap-2 items-center">
<p class="ml-2">Show & hide people</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({countTotalPeople.toLocaleString($locale)})</p>

View File

@ -10,6 +10,7 @@
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
export let user: UserResponseDto;
export let canResetPassword = true;
@ -90,76 +91,78 @@
}
</script>
<div
class="relative max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="absolute top-0 right-0 px-2 py-2 h-fit">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
</div>
<FocusTrap>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
class="relative max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<Icon path={mdiAccountEditOutline} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit user</h1>
</div>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
<div class="absolute top-0 right-0 px-2 py-2 h-fit">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiAccountEditOutline} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit user</h1>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
>Quota Size (GiB) {#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}</label
>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>Note: Enter 0 for unlimited quota</p>
</div>
<form on:submit|preventDefault={editUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
Storage Migration Job</a
<div class="m-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
>Quota Size (GiB) {#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
{/if}</label
>
</p>
</div>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>Note: Enter 0 for unlimited quota</p>
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="mt-8 flex w-full gap-4 px-4">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
>Reset password</Button
>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
Storage Migration Job</a
>
</p>
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
</div>
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="mt-8 flex w-full gap-4 px-4">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
>Reset password</Button
>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
</div>
</FocusTrap>
{#if isShowResetPasswordConfirmation}
<ConfirmDialogue

View File

@ -8,7 +8,7 @@
import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { shortcuts } from '$lib/utils/shortcut';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { ThumbnailFormat, getMemoryLane } from '@immich/sdk';
@ -102,13 +102,18 @@
<ControlAppBar on:close={() => goto(AppRoute.PHOTOS)} forceDark>
<svelte:fragment slot="leading">
<p class="text-lg">
{currentMemory.title}
{memoryLaneTitle(currentMemory.yearsAgo)}
</p>
</svelte:fragment>
{#if canGoForward}
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
<CircleIconButton icon={paused ? mdiPlay : mdiPause} forceDark on:click={() => (paused = !paused)} />
<CircleIconButton
title={paused ? 'Play memories' : 'Pause memories'}
icon={paused ? mdiPlay : mdiPause}
forceDark
on:click={() => (paused = !paused)}
/>
{#each currentMemory.assets as _, index}
<button
@ -144,7 +149,7 @@
class:opacity-100={galleryInView}
>
<button on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })} disabled={!galleryInView}>
<CircleIconButton icon={mdiChevronUp} backgroundColor="white" forceDark />
<CircleIconButton title="Hide gallery" icon={mdiChevronUp} backgroundColor="white" forceDark />
</button>
</div>
{/if}
@ -181,7 +186,7 @@
{#if previousMemory}
<div class="absolute bottom-4 right-4 text-left text-white">
<p class="text-xs font-semibold text-gray-200">PREVIOUS</p>
<p class="text-xl">{previousMemory.title}</p>
<p class="text-xl">{memoryLaneTitle(previousMemory.yearsAgo)}</p>
</div>
{/if}
</button>
@ -204,13 +209,23 @@
<!-- CONTROL BUTTONS -->
{#if canGoBack}
<div class="absolute top-1/2 left-0 ml-4">
<CircleIconButton icon={mdiChevronLeft} backgroundColor="#202123" on:click={toPrevious} />
<CircleIconButton
title="Previous memory"
icon={mdiChevronLeft}
backgroundColor="#202123"
on:click={toPrevious}
/>
</div>
{/if}
{#if canGoForward}
<div class="absolute top-1/2 right-0 mr-4">
<CircleIconButton icon={mdiChevronRight} backgroundColor="#202123" on:click={toNext} />
<CircleIconButton
title="Next memory"
icon={mdiChevronRight}
backgroundColor="#202123"
on:click={toNext}
/>
</div>
{/if}
@ -254,7 +269,7 @@
{#if nextMemory}
<div class="absolute bottom-4 left-4 text-left text-white">
<p class="text-xs font-semibold text-gray-200">UP NEXT</p>
<p class="text-xl">{nextMemory.title}</p>
<p class="text-xl">{memoryLaneTitle(nextMemory.yearsAgo)}</p>
</div>
{/if}
</button>
@ -271,7 +286,7 @@
class:opacity-100={!galleryInView}
>
<button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
<CircleIconButton icon={mdiChevronDown} backgroundColor="white" forceDark />
<CircleIconButton title="Show gallery" icon={mdiChevronDown} backgroundColor="white" forceDark />
</button>
</div>

View File

@ -1,19 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { createAlbum, type AlbumResponseDto } from '@immich/sdk';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import type { AlbumResponseDto } from '@immich/sdk';
import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
export let shared = false;
let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext();
@ -24,26 +19,12 @@
closeMenu();
};
const handleAddToNewAlbum = (albumName: string) => {
const handleAddToNewAlbum = async (albumName: string) => {
showAlbumPicker = false;
closeMenu();
const assetIds = [...getAssets()].map((asset) => asset.id);
createAlbum({ createAlbumDto: { albumName, assetIds } })
.then(async (response) => {
const { id, albumName } = response;
notificationController.show({
message: `Added ${assetIds.length} to ${albumName}`,
type: NotificationType.Info,
});
clearSelect();
await goto(`${AppRoute.ALBUMS}/${id}`);
})
.catch((error) => {
console.error(`[add-to-album.svelte]:handleAddToNewAlbum ${error}`, error);
});
await addAssetsToNewAlbum(albumName, assetIds);
};
const handleAddToAlbum = async (album: AlbumResponseDto) => {

View File

@ -3,7 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { ThumbnailFormat, getMemoryLane } from '@immich/sdk';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
@ -66,7 +66,7 @@
</div>
{/if}
<div class="inline-block" bind:offsetWidth={innerWidth}>
{#each $memoryStore as memory, index (memory.title)}
{#each $memoryStore as memory, index (memory.yearsAgo)}
<button
class="memory-card relative mr-8 inline-block aspect-video h-[215px] rounded-xl"
on:click={() => goto(`${AppRoute.MEMORY}?${QueryParameter.MEMORY_INDEX}=${index}`)}
@ -77,7 +77,9 @@
alt={`Memory Lane ${getAltText(memory.assets[0])}`}
draggable="false"
/>
<p class="absolute bottom-2 left-4 z-10 text-lg text-white">{memory.title}</p>
<p class="absolute bottom-2 left-4 z-10 text-lg text-white">
{memoryLaneTitle(memory.yearsAgo)}
</p>
<div
class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
/>

View File

@ -5,6 +5,7 @@
import { createEventDispatcher, onMount } from 'svelte';
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
import BaseModal from './base-modal.svelte';
import { normalizeSearchString } from '$lib/utils/string-utils';
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
@ -30,7 +31,7 @@
filteredAlbums =
search.length > 0 && albums.length > 0
? albums.filter((album) => {
return album.albumName.toLowerCase().includes(search.toLowerCase());
return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
})
: albums;
}
@ -44,7 +45,7 @@
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<BaseModal on:close on:escape>
<svelte:fragment slot="title">
<span class="flex place-items-center gap-2">
<p class="font-medium">
@ -84,7 +85,7 @@
<Icon path={mdiPlus} size="30" />
</div>
<p class="">
New {shared ? 'Shared ' : ''}Album {#if search.length > 0}<b>{search}</b>{/if}
New Album {#if search.length > 0}<b>{search}</b>{/if}
</p>
</button>
{#if filteredAlbums.length > 0}

View File

@ -6,13 +6,13 @@
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiClose } from '@mdi/js';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
const dispatch = createEventDispatcher<{
escape: void;
close: void;
}>();
export let zIndex = 9999;
export let ignoreClickOutside = false;
onMount(() => {
if (browser) {
@ -34,36 +34,40 @@
});
</script>
<div
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50"
>
<FocusTrap>
<div
use:clickOutside
on:outclick={() => !ignoreClickOutside && dispatch('close')}
on:escape={() => dispatch('escape')}
class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-lg bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50"
>
<div class="flex place-items-center justify-between px-5 py-3">
<div
use:clickOutside={{
onOutclick: () => dispatch('close'),
onEscape: () => dispatch('escape'),
}}
class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-lg bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
tabindex="-1"
>
<div class="flex place-items-center justify-between px-5 py-3">
<div>
<slot name="title">
<p>Modal Title</p>
</slot>
</div>
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} size={'20'} title="Close" />
</div>
<div>
<slot name="title">
<p>Modal Title</p>
</slot>
<slot />
</div>
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} size={'20'} />
{#if $$slots['sticky-bottom']}
<div class="sticky bottom-0 bg-immich-bg px-5 pb-5 pt-3 dark:bg-immich-dark-gray">
<slot name="sticky-bottom" />
</div>
{/if}
</div>
<div>
<slot />
</div>
{#if $$slots['sticky-bottom']}
<div class="sticky bottom-0 bg-immich-bg px-5 pb-5 pt-3 dark:bg-immich-dark-gray">
<slot name="sticky-bottom" />
</div>
{/if}
</div>
</div>
</FocusTrap>

View File

@ -185,7 +185,8 @@
},
{
shortcut: { key: 'Escape' },
onShortcut: () => {
onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
},
},

View File

@ -14,7 +14,7 @@
{#if text}
{#if icon}
<p class="flex gap-2">
<Icon path={icon} size="18" />
<Icon path={icon} ariaHidden={true} size="18" />
{text}
</p>
{:else}

Some files were not shown because too many files have changed in this diff Show More