Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Tran
da7fbcbb46 fix: test 2025-08-25 15:28:25 -05:00
Alex Tran
b471e190a0 fix: test 2025-08-25 15:13:36 -05:00
Alex Tran
7672c8c6e0 fix: test 2025-08-25 15:01:47 -05:00
Alex Tran
386a6bb377 fix: sharp issue with arm64 build 2025-08-25 14:53:07 -05:00
32 changed files with 105 additions and 390 deletions

View File

@@ -12,7 +12,8 @@ services:
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules

View File

@@ -8,18 +8,8 @@ name: Close likely duplicates
permissions: {}
jobs:
should_run:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.should_run.outputs.run }}
steps:
- id: should_run
run: echo "run=${{ github.event_name == 'issues' || github.event.discussion.category.name == 'Feature Request' }}" >> $GITHUB_OUTPUT
get_body:
runs-on: ubuntu-latest
needs: should_run
if: ${{ needs.should_run.outputs.should_run == 'true' }}
env:
EVENT: ${{ toJSON(github.event) }}
outputs:
@@ -32,8 +22,7 @@ jobs:
get_checkbox_json:
runs-on: ubuntu-latest
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
needs: get_body
container:
image: yshavit/mdq:0.8.0@sha256:c69224d34224a0043d9a3ee46679ba4a2a25afaac445f293d92afe13cd47fcea
outputs:
@@ -48,8 +37,8 @@ jobs:
close_and_comment:
runs-on: ubuntu-latest
needs: [get_checkbox_json, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' && !fromJSON(needs.get_checkbox_json.outputs.json).items[0].list[0].checked }}
needs: get_checkbox_json
if: ${{ !fromJSON(needs.get_checkbox_json.outputs.json).items[0].list[0].checked }}
permissions:
issues: write
discussions: write

View File

@@ -33,7 +33,8 @@ services:
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -96,7 +97,8 @@ services:
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -192,7 +194,8 @@ services:
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
volumes:
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
@@ -207,7 +210,8 @@ volumes:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
server-node-modules:
server-dist:
web-node_modules:
github-node_modules:
cli-node_modules:

View File

@@ -1,31 +1,5 @@
# FAQ
## Commercial Guidelines
### Are you open to commercial partnerships and collaborations?
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
### What are your guidelines for resellers and trademark usage?
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
## User
### How can I reset the admin password?

View File

@@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
## Auto Launch
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
## Mobile Redirect URI

View File

@@ -38,7 +38,7 @@ services:
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

View File

@@ -1941,9 +1941,7 @@
"to_change_password": "Change password",
"to_favorite": "Favorite",
"to_login": "Login",
"to_multi_select": "to multi-select",
"to_parent": "Go to parent",
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"total": "Total",

View File

@@ -75,8 +75,6 @@ profileChangedAt: $profileChangedAt
bool? isPartnerSharedWith,
bool? hasProfileImage,
DateTime? profileChangedAt,
int? quotaSizeInBytes,
int? quotaUsageInBytes,
}) => UserDto(
id: id ?? this.id,
email: email ?? this.email,
@@ -90,8 +88,6 @@ profileChangedAt: $profileChangedAt
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
);
@override
@@ -109,9 +105,7 @@ profileChangedAt: $profileChangedAt
other.memoryEnabled == memoryEnabled &&
other.inTimeline == inTimeline &&
other.hasProfileImage == hasProfileImage &&
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
}
@override
@@ -127,9 +121,7 @@ profileChangedAt: $profileChangedAt
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
hasProfileImage.hashCode ^
profileChangedAt.hashCode ^
quotaSizeInBytes.hashCode ^
quotaUsageInBytes.hashCode;
profileChangedAt.hashCode;
}
class PartnerUserDto {

View File

@@ -54,8 +54,6 @@ class User {
avatarColor: dto.avatarColor,
memoryEnabled: dto.memoryEnabled,
inTimeline: dto.inTimeline,
quotaUsageInBytes: dto.quotaUsageInBytes,
quotaSizeInBytes: dto.quotaSizeInBytes,
);
UserDto toDto() => UserDto(

View File

@@ -65,53 +65,40 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final cacheManager = this.cacheManager;
final streamController = StreamController<List<int>>(sync: true);
final Stream<List<int>> stream;
cacheManager?.putStreamedFile(url, streamController.stream);
stream = response.map((chunk) {
if (_isCancelled) {
throw StateError('Cancelled request');
}
if (cacheManager != null) {
streamController.add(chunk);
}
return chunk;
});
try {
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
streamController.close();
return await ImmutableBuffer.fromUint8List(bytes);
} catch (e) {
streamController.addError(e);
streamController.close();
if (_isCancelled) {
return null;
}
rethrow;
}
}
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
// Handle unknown content length from reverse proxy
final contentLength = response.contentLength;
final Uint8List bytes;
int offset = 0;
if (length > 0) {
if (contentLength >= 0) {
// Known content length - use pre-allocated buffer
bytes = Uint8List(length);
await stream.listen((chunk) {
bytes = Uint8List(contentLength);
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
if (_isCancelled) {
throw StateError('Cancelled request');
}
bytes.setAll(offset, chunk);
offset += chunk.length;
}, cancelOnError: true).asFuture();
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
} else {
// Unknown content length - collect chunks dynamically
final chunks = <List<int>>[];
int totalLength = 0;
await stream.listen((chunk) {
final subscription = response.listen((List<int> chunk) {
// this is important to break the response stream if the request is cancelled
if (_isCancelled) {
throw StateError('Cancelled request');
}
chunks.add(chunk);
totalLength += chunk.length;
}, cancelOnError: true).asFuture();
}, cancelOnError: true);
cacheManager?.putStreamedFile(url, response);
await subscription.asFuture();
// Combine all chunks into a single buffer
bytes = Uint8List(totalLength);
for (final chunk in chunks) {
bytes.setAll(offset, chunk);
@@ -119,7 +106,7 @@ class RemoteImageRequest extends ImageRequest {
}
}
return bytes;
return await ImmutableBuffer.fromUint8List(bytes);
}
Future<ImageInfo?> _loadCachedFile(

View File

@@ -29,8 +29,6 @@ abstract final class UserConverter {
isPartnerSharedWith: false,
profileChangedAt: adminDto.profileChangedAt,
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
);
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(

View File

@@ -94,7 +94,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
imageInfo.dispose();
return;
}
_fadeController.value = 1.0;
setState(() {
_providerImage = imageInfo.image;
});
@@ -115,7 +115,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
final imageStreamListener = _imageStreamListener = ImageStreamListener(
(ImageInfo imageInfo, bool synchronousCall) {
_stopListeningToThumbhashStream();
_stopListeningToStream();
if (!mounted) {
imageInfo.dispose();
return;
@@ -125,7 +125,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
return;
}
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
if (synchronousCall && _providerImage == null) {
_fadeController.value = 1.0;
} else if (_fadeController.isAnimating) {
_fadeController.forward();
@@ -201,15 +201,6 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
_loadFromThumbhashProvider();
}
bool _isVisible() {
final renderObject = context.findRenderObject() as RenderBox?;
if (renderObject == null || !renderObject.attached) return false;
final topLeft = renderObject.localToGlobal(Offset.zero);
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
return topLeft.dy < context.height && bottomRight.dy > 0;
}
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;

View File

@@ -57,10 +57,7 @@ class TimelineHeader extends StatelessWidget {
if (isMonthHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatMonth(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
),
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
const Spacer(),
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],
@@ -68,10 +65,7 @@ class TimelineHeader extends StatelessWidget {
if (isDayHeader)
Row(
children: [
Text(
toBeginningOfSentenceCase(_formatDay(context, date)),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
const Spacer(),
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
],

View File

@@ -38,21 +38,9 @@ abstract class RemoteCacheManager extends CacheManager {
final file = await store.fileSystem.createFile(path);
final sink = file.openWrite();
try {
await source.listen(sink.add, cancelOnError: true).asFuture();
await source.pipe(sink);
} catch (e) {
try {
await sink.close();
await file.delete();
} catch (e) {
_log.severe('Failed to delete incomplete cache file: $e');
}
return;
}
try {
await sink.flush();
await sink.close();
} catch (e) {
try {
await file.delete();
} catch (e) {

View File

@@ -91,7 +91,11 @@ FROM prod-builder-base AS server-prod
WORKDIR /usr/src/app
COPY ./package* ./pnpm* .pnpmfile.cjs ./
COPY ./server ./server/
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
## Build server with sharp linked against system (global) libvips instead of vendored copy.
## Using SHARP_IGNORE_GLOBAL_LIBVIPS previously caused arm64 (e.g. Raspberry Pi) illegal instruction
## crashes due to the prebuilt vendored libvips targeting newer ARM features. Force global libvips
## during build so the already-present distro libvips (built with conservative flags) is used.
RUN SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
# web production build

View File

@@ -1,79 +0,0 @@
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserAdminCreateDto } from 'src/dtos/user.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserAdminService } from 'src/services/user-admin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(UserAdminController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(UserAdminService);
beforeAll(async () => {
ctx = await controllerSetup(UserAdminController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: UserAdminService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
password: 'test',
name: 'Test User',
quotaSizeInBytes: 1.2,
};
const { status, body } = await request(ctx.getHttpServer())
.post(`/admin/users`)
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
describe('GET /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/admin/users/${factory.uuid()}`)
.set('Authorization', `Bearer token`)
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
});

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
storageLabel?: string | null;
@Optional({ nullable: true })
@IsInt()
@IsNumber()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
shouldChangePassword?: boolean;
@Optional({ nullable: true })
@IsInt()
@IsNumber()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;

View File

@@ -38,11 +38,7 @@ from
select
"album".*,
coalesce(
json_agg(
"assets"
order by
"assets"."fileCreatedAt" asc
) filter (
json_agg("assets") filter (
where
"assets"."id" is not null
),

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
@@ -68,6 +68,12 @@ const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('person');
};
const withAsset = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
'asset',
);
};
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
@@ -475,12 +481,7 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
'asset',
),
)
.$narrowType<{ asset: NotNull }>()
.select(withAsset)
.select(withPerson)
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)

View File

@@ -86,16 +86,7 @@ export class SharedLinkRepository {
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn
.coalesce(
eb.fn
.jsonAgg('assets')
.orderBy('assets.fileCreatedAt', 'asc')
.filterWhere('assets.id', 'is not', null),
sql`'[]'`,
)
.as('assets'),
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
)
.select((eb) => eb.fn.toJson('owner').as('owner'))
.groupBy(['album.id', sql`"owner".*`])

View File

@@ -42,6 +42,7 @@ describe(MediaService.name, () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
await sut.handleQueueGenerateThumbnails({ force: true });

View File

@@ -197,10 +197,6 @@ export class PersonService extends BaseService {
throw new BadRequestException('Invalid assetId for feature face');
}
if (face.asset.isOffline) {
throw new BadRequestException('An offline asset cannot be used for feature face');
}
faceId = face.id;
}

View File

@@ -33,7 +33,6 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
@@ -287,7 +286,6 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
case StackRepository:
case SyncRepository:
case SyncCheckpointRepository:
@@ -393,7 +391,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
checksum: randomBytes(32),
type: AssetType.Image,
originalPath: '/path/to/something.jpg',
ownerId: 'not-a-valid-uuid',
ownerId: '@immich.cloud',
isFavorite: false,
fileCreatedAt: now,
fileModifiedAt: now,

View File

@@ -1,65 +0,0 @@
import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { SharedLinkService } from 'src/services/shared-link.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(SharedLinkService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, SharedLinkRepository],
mock: [LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SharedLinkService.name, () => {
describe('get', () => {
it('should return the correct dates on the shared link album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z'];
for (const date of dates) {
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
}
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
album: expect.objectContaining({
startDate: '2020-01-01T00:00:00+00:00',
endDate: '2022-01-01T00:00:00+00:00',
}),
});
});
});
});

View File

@@ -7,10 +7,12 @@
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetMediaSize } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { swipe } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
@@ -38,6 +40,7 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -46,6 +49,7 @@
showVideo = true;
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
forceMuted = false;
videoPlayer.load();
}
});
@@ -63,27 +67,23 @@
onVideoStarted();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
await tryForceMutedPlay(video);
return;
}
// auto-play failed
handleError(error, $t('errors.unable_to_play_video'));
} finally {
isLoading = false;
}
};
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
if (video.muted) {
return;
}
try {
video.muted = true;
await handleCanPlay(video);
} catch {
// muted auto-play failed
} catch (error) {
handleError(error, $t('errors.unable_to_play_video'));
}
};
@@ -134,14 +134,18 @@
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}

View File

@@ -419,22 +419,14 @@ export class TimelineManager {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) {
return monthGroup;
}
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) {
return;
}
const asset = toTimelineAsset(response);
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
if (!asset || this.isExcluded(asset)) {
return;
}
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) {
return monthGroup;

View File

@@ -7,8 +7,7 @@
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiKeyboardReturn } from '@mdi/js';
import { Button, Modal, ModalBody } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@@ -75,9 +74,9 @@
};
const handleMultiSubmit = () => {
const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id)));
if (selectedAlbums.size > 0) {
onClose([...selectedAlbums]);
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
if (albums.size > 0) {
onClose([...albums]);
} else {
onClose();
}
@@ -200,22 +199,4 @@
>
{/if}
</ModalBody>
<ModalFooter>
<div class="flex justify-around w-full">
<div class="flex gap-4">
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Icon icon={mdiKeyboardReturn} size="1rem" />
</span>
<Text size="tiny">{$t('to_select')}</Text>
</div>
<div class="flex gap-1 place-items-center">
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
<Text size="tiny">CTRL</Text>
</span>
<Text size="tiny">{$t('to_multi_select')}</Text>
</div>
</div>
</div>
</ModalFooter>
</Modal>

View File

@@ -122,7 +122,7 @@
</Field>
<Field label={$t('admin.quota_size_gib')}>
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if}

View File

@@ -83,7 +83,6 @@
name="quotaSize"
placeholder={$t('unlimited')}
type="number"
step="1"
min="0"
bind:value={quotaSize}
/>

View File

@@ -403,7 +403,6 @@
const handleShareLink = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
if (sharedLink) {
await refreshAlbum();
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
};
@@ -412,7 +411,7 @@
const changed = await modalManager.show(AlbumUsersModal, { album });
if (changed) {
await refreshAlbum();
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
}
};

View File

@@ -1,4 +1,4 @@
import { handleCancel, handlePreload } from './request';
import { cancelRequest, handleRequest } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
@@ -7,19 +7,12 @@ export const installBroadcastChannelListener = () => {
if (!event.data) {
return;
}
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
const urlString = event.data.url;
const url = new URL(urlString, event.origin);
if (event.data.type === 'cancel') {
cancelRequest(url);
} else if (event.data.type === 'preload') {
handleRequest(url);
}
};
};

View File

@@ -1,7 +1,5 @@
import { get, put } from './cache';
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
@@ -23,16 +21,11 @@ const getCacheKey = (request: URL | Request) => {
throw new Error(`Invalid request: ${request}`);
};
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
const pendingRequests = new Map<string, AbortController>();
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
@@ -48,26 +41,23 @@ export const handleRequest = async (request: URL | Request) => {
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
console.log(error);
return new Response(undefined, {
status: 499,
statusText: `Request canceled: Instructions unclear, accidentally interrupted myself (${error})`,
});
} finally {
pendingRequests.delete(cacheKey);
}
};
export const handleCancel = (url: URL) => {
export const cancelRequest = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
const pending = pendingRequests.get(cacheKey);
if (!pending) {
return;
}
pendingRequest.abort();
pending.abort();
pendingRequests.delete(cacheKey);
};