Compare commits

..

3 Commits

Author SHA1 Message Date
bwees 0b2d9083eb fix: remove 1.106.0 check
(we dont support v1 servers anymore)
2026-06-13 12:53:49 -05:00
bwees cdb46728c1 chore: readd major version compatabilty messages 2026-06-13 12:03:13 -05:00
bwees 90951521ca refactor: use SemVer classes for version compatability message 2026-06-13 11:47:36 -05:00
30 changed files with 196 additions and 240 deletions
+6 -47
View File
@@ -2,7 +2,6 @@ import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
@@ -10,48 +9,28 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => {
let websocket: Socket;
let partnerWebsocket: Socket;
let admin: LoginResponseDto;
let partner: LoginResponseDto;
let partnerArchivedAssetId: string;
let adminArchivedAssetId: string;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
partner = await utils.userSetup(admin.accessToken, createUserDto.user1);
websocket = await utils.connectWebsocket(admin.accessToken);
partnerWebsocket = await utils.connectWebsocket(partner.accessToken);
const adminFiles = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
const adminArchivedFile = 'metadata/dates/datetimeoriginal-gps.jpg';
const partnerFile = 'metadata/gps-position/thompson-springs.jpg';
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents();
const uploadFile = async (accessToken: string, input: string) => {
const uploadFile = async (input: string) => {
const filepath = join(testAssetDir, input);
const { id } = await utils.createAsset(accessToken, {
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
return id;
};
await Promise.all(adminFiles.map((f) => uploadFile(admin.accessToken, f)));
[adminArchivedAssetId, partnerArchivedAssetId] = await Promise.all([
uploadFile(admin.accessToken, adminArchivedFile),
uploadFile(partner.accessToken, partnerFile),
]);
await Promise.all([
utils.archiveAssets(admin.accessToken, [adminArchivedAssetId]),
utils.archiveAssets(partner.accessToken, [partnerArchivedAssetId]),
utils.createPartner(partner.accessToken, admin.userId),
]);
await Promise.all(files.map((f) => uploadFile(f)));
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
utils.disconnectWebsocket(partnerWebsocket);
});
describe('GET /map/markers', () => {
@@ -61,6 +40,7 @@ describe('/map', () => {
expect(body).toEqual(errorDto.unauthorized);
});
// TODO archive one of these assets
it('should get map markers for all non-archived assets', async () => {
const { status, body } = await request(app)
.get('/map/markers')
@@ -89,28 +69,7 @@ describe('/map', () => {
]);
});
it('should not expose partner archived asset locations', async () => {
const { status, body } = await request(app)
.get('/map/markers')
.query({ withPartners: true, isArchived: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
const ids = body.map((m: { id: string }) => m.id);
expect(ids).not.toContain(partnerArchivedAssetId);
expect(ids).toContain(adminArchivedAssetId);
});
it('should include own archived asset locations', async () => {
const { status, body } = await request(app)
.get('/map/markers')
.query({ isArchived: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.map((m: { id: string }) => m.id)).toContain(adminArchivedAssetId);
});
// TODO archive one of these assets
it('should get all map markers', async () => {
const { status, body } = await request(app)
.get('/map/markers')
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -15,7 +15,6 @@ const Map<String, Locale> locales = {
'Czech (cs)': Locale('cs'),
'Danish (da)': Locale('da'),
'Dutch (nl)': Locale('nl'),
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
'Estonian (et)': Locale('et'),
'Filipino (tl)': Locale('tl'),
'Finnish (fi)': Locale('fi'),
@@ -142,6 +142,7 @@ class AppConfig {
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
.shareFileType => share.fileType,
.slideshowTransition => slideshow.transition,
.slideshowRepeat => slideshow.repeat,
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
@@ -195,6 +196,7 @@ class AppConfig {
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
@@ -1,38 +1,48 @@
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool transition;
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
const SlideshowConfig({
this.transition = true,
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.blurredBackground,
this.look = SlideshowLook.contain,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) =>
SlideshowConfig(
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
SlideshowConfig copyWith({
bool? transition,
bool? repeat,
int? duration,
SlideshowLook? look,
SlideshowDirection? direction,
}) => SlideshowConfig(
transition: transition ?? this.transition,
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.transition == transition &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(repeat, duration, look, direction);
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
@override
String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
String toString() =>
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
}
@@ -70,6 +70,7 @@ enum SettingsKey<T> {
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
@@ -33,9 +33,7 @@ class DriftSlideshowPage extends ConsumerStatefulWidget {
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with SingleTickerProviderStateMixin {
static const double _kenBurnsZoom = 0.1;
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
late SlideshowConfig _config;
late final PageController _pageController;
late final Stopwatch _stopwatch;
@@ -45,12 +43,6 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
bool _paused = false;
bool _showAppBar = false;
late final AnimationController _crossfadeController;
late final Animation<double> _crossfadeOpacity;
int? _crossfadeFromIndex;
int? _crossfadeToIndex;
int _zoomCycle = 0;
@override
initState() {
super.initState();
@@ -58,8 +50,6 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
final asset = ref.read(assetViewerProvider).currentAsset;
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
_pageController = PageController(initialPage: _index);
_crossfadeController = AnimationController(vsync: this, duration: Durations.extralong2);
_crossfadeOpacity = Tween<double>(begin: 1.0, end: 0.0).animate(_crossfadeController);
_stopwatch = Stopwatch();
_createTimer();
_updateNextIndex();
@@ -74,7 +64,6 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_timer.cancel();
_stopwatch.stop();
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
@@ -161,64 +150,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
await widget.timeline.preloadAssets(_nextIndex);
}
_crossFadeToPage(_nextIndex);
}
void _crossFadeToPage(int page) {
final previousIndex = _index;
_pageController.jumpToPage(page);
setState(() {
_crossfadeFromIndex = previousIndex;
_crossfadeToIndex = page;
});
_crossfadeController.forward(from: 0.0).whenComplete(() {
if (mounted) {
setState(() {
_crossfadeFromIndex = null;
_crossfadeToIndex = null;
});
}
});
}
Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) {
final asset = widget.timeline.getAssetSafe(index);
final Widget child;
if (isIncoming && asset?.isImage == true) {
child = _getPhotoView(context, index);
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
_pageController.jumpToPage(_nextIndex);
} else {
final zoomOut = isIncoming ? _zoomCycle.isOdd : _zoomCycle.isEven;
final zoom = isIncoming ? (zoomOut ? 1.0 : 0.0) : (zoomOut ? 0.0 : 1.0);
child = _getCrossfadeChild(context, index, zoom);
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
}
return Stack(
fit: StackFit.expand,
children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child],
);
}
Widget _getCrossfadeChild(BuildContext context, int index, double zoom) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return const SizedBox.shrink();
}
final scale = _config.look == SlideshowLook.cover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained;
return PhotoView(
imageProvider: getFullImageProvider(asset, size: context.sizeData),
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + zoom * _kenBurnsZoom),
controller: PhotoViewController(),
);
}
void _createTimer() {
@@ -236,7 +172,6 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
setState(() {
_index = page;
_zoomCycle++;
if (!asset.isImage) {
_paused = false;
@@ -333,7 +268,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
final zoomOut = _zoomCycle.isOdd;
final zoomOut = index % 2 == 1;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
@@ -354,7 +289,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value * _kenBurnsZoom),
initialScale: scale * (1.0 + value / 10.0),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
@@ -421,43 +356,20 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
extendBody: true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
),
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
if (_crossfadeFromIndex != null && _crossfadeToIndex != null)
Positioned.fill(
child: IgnorePointer(
child: Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
FadeTransition(
opacity: _crossfadeController,
child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true),
),
FadeTransition(
opacity: _crossfadeOpacity,
child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false),
),
],
),
),
),
],
),
),
);
}
+12 -3
View File
@@ -1,7 +1,16 @@
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
// Add latest compat info up top
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
}
return null;
+4 -12
View File
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
@@ -16,11 +16,15 @@ class SlideshowSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final slideshow = ref.read(appConfigProvider).slideshow;
final useTransition = useState(slideshow.transition);
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
useValueChanged<bool, void>(useTransition.value, (_, __) {
ref.read(settingsProvider).write(.slideshowTransition, useTransition.value);
});
useValueChanged<bool, void>(useRepeat.value, (_, __) {
ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value);
});
@@ -41,6 +45,11 @@ class SlideshowSettings extends HookConsumerWidget {
title: 'slideshow'.t(context: context),
icon: Icons.slideshow_outlined,
),
SettingsSwitchListTile(
valueNotifier: useTransition,
title: "show_slideshow_transition".t(context: context),
enabled: useDirection.value != SlideshowDirection.shuffle,
),
SettingsSwitchListTile(
valueNotifier: useRepeat,
title: "slideshow_repeat".t(context: context),
+4 -4
View File
@@ -366,18 +366,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_sqlite_async:
dependency: "direct main"
description:
+2 -2
View File
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.34.0
drift: ^2.32.1
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
@@ -96,7 +96,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0
build_runner: ^2.13.1
# Drift generator
drift_dev: ^2.34.0
drift_dev: ^2.32.1
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4
@@ -1,29 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
test('getVersionCompatibilityMessage', () {
String? result;
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
}
+2 -3
View File
@@ -78,9 +78,8 @@ export class MapRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], [DummyValue.UUID]] })
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
getMapMarkers(
authUserId: string,
ownerIds: string[],
albumIds: string[],
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
@@ -90,7 +89,7 @@ export class MapRepository {
qb.where((eb) =>
eb.or([
eb('asset.visibility', '=', AssetVisibility.Timeline),
eb.and([eb('asset.ownerId', '=', authUserId), eb('asset.visibility', '=', AssetVisibility.Archive)]),
eb('asset.visibility', '=', AssetVisibility.Archive),
]),
),
)
-1
View File
@@ -59,7 +59,6 @@ describe(MapService.name, () => {
const markers = await sut.getMapMarkers(auth, { withPartners: true });
expect(mocks.map.getMapMarkers).toHaveBeenCalledWith(
auth.user.id,
[auth.user.id, partner.sharedById],
expect.arrayContaining([]),
{ withPartners: true },
+1 -1
View File
@@ -15,7 +15,7 @@ export class MapService extends BaseService {
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
return this.mapRepository.getMapMarkers(auth.user.id, userIds, albumIds, options);
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
}
async reverseGeocode(dto: MapReverseGeocodeDto) {
+4 -4
View File
@@ -261,6 +261,10 @@
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
@@ -286,10 +290,6 @@
{/if}
</div>
{#if show.brokenAsset}
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
{/if}
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
@@ -15,7 +15,7 @@
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -82,7 +82,7 @@
$effect(() => {
const asset = assetViewerManager.asset;
if (asset) {
handlePromiseError(loadCloseAssets(asset));
untrack(() => handlePromiseError(loadCloseAssets(asset)));
}
});
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { SearchOptions } from '$lib/utils/dipatch';
import { IconButton, LoadingSpinner } from '@immich/ui';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -8,7 +9,7 @@
roundedBottom?: boolean;
showLoadingSpinner: boolean;
placeholder: string;
onSearch?: (options: { force?: boolean }) => void;
onSearch?: (options: SearchOptions) => void;
onReset?: () => void;
}
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
#isImageLoading = $derived.by(() => {
const quality = this.imageLoaderStatus?.quality;
if (!quality || this.imageLoaderStatus?.hasError) {
if (!quality) {
return false;
}
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
+7
View File
@@ -0,0 +1,7 @@
export interface ResetOptions {
default?: boolean;
}
export interface SearchOptions {
force?: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
import type { AssetResponseDto } from '@immich/sdk';
import { getExifCount } from '$lib/utils/exif-utils';
describe('getting the exif count', () => {
it('returns 0 when exifInfo is undefined', () => {
const asset = {};
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
});
it('returns 0 when exifInfo is empty', () => {
const asset = { exifInfo: {} };
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
});
it('returns the correct count of non-null exifInfo properties', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: null } };
expect(getExifCount(asset as AssetResponseDto)).toBe(2);
});
it('ignores null, undefined and empty properties in exifInfo', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: null, fNumber: undefined, description: '' } };
expect(getExifCount(asset as AssetResponseDto)).toBe(1);
});
it('returns the correct count when all exifInfo properties are non-null', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1, description: 'test' } };
expect(getExifCount(asset as AssetResponseDto)).toBe(4);
});
});
+5
View File
@@ -0,0 +1,5 @@
import type { AssetResponseDto } from '@immich/sdk';
export const getExifCount = (asset: AssetResponseDto) => {
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
};
+10
View File
@@ -5,3 +5,13 @@ export const removeAccents = (str: string) => {
export const normalizeSearchString = (str: string) => {
return removeAccents(str.toLocaleLowerCase());
};
export const buildDateString = (year: number, month?: number, day?: number) => {
return [
year.toString(),
month && !Number.isNaN(month) ? month.toString() : undefined,
day && !Number.isNaN(day) ? day.toString() : undefined,
]
.filter((date) => date !== undefined)
.join('-');
};
+7
View File
@@ -30,6 +30,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description');
}
default: {
return type;
}
}
};
@@ -76,6 +79,10 @@ export const getWorkflowDefaultConfig = (schema: JSONSchemaProperty) => {
config[key] = property.properties ? getWorkflowDefaultConfig(property) : {};
break;
}
default: {
console.log(`Unknown configuration type: ${property.type}`);
}
}
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { lang, locale } from '$lib/stores/preferences.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -30,8 +30,7 @@
initialVisibleCount = 5,
}: Props = $props();
const listFormat = $derived(new Intl.ListFormat($lang));
const isFromExternalLibrary = $derived(!!asset.libraryId);
let isFromExternalLibrary = $derived(!!asset.libraryId);
const visibleMetadataItems = $derived(
getAllMetadataItems(asset, $t, $locale)
@@ -117,13 +116,7 @@
{#await getAllAlbums({ assetId: asset.id })}
{$t('scanning_for_album')}
{:then albums}
{#if albums.length === 1}
{albums[0].albumName}
{:else}
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
{$t('in_albums', { values: { count: albums.length } })}
</span>
{/if}
{$t('in_albums', { values: { count: albums.length } })}
{/await}
</InfoRow>
</div>
+1 -1
View File
@@ -37,7 +37,7 @@
}
uploadAssetsStore.reset();
}}
class="fixed inset-e-16 bottom-6 z-60"
class="fixed inset-e-16 bottom-6"
>
{#if showDetail}
<div
@@ -88,7 +88,6 @@
desc={$t('admin.transcoding_accepted_video_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
lockedOptions={[configToEdit.ffmpeg.targetVideoCodec]}
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
@@ -107,7 +106,6 @@
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
lockedOptions={[configToEdit.ffmpeg.targetAudioCodec]}
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
{ value: AudioCodec.Mp3, text: 'MP3' },
@@ -1,18 +1,17 @@
<script lang="ts" generics="T extends string">
<script lang="ts">
import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: T[];
options: { value: T; text: string }[];
value: string[];
options: { value: string; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
disabled?: boolean;
lockedOptions?: T[];
}
let {
@@ -23,10 +22,9 @@
name = '',
isEdited = false,
disabled = false,
lockedOptions = [],
}: Props = $props();
function handleCheckboxChange(option: T) {
function handleCheckboxChange(option: string) {
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
}
</script>
@@ -59,7 +57,7 @@
size="tiny"
id="{option.value}-checkbox"
checked={value.includes(option.value)}
disabled={disabled || lockedOptions.includes(option.value)}
{disabled}
onCheckedChange={() => handleCheckboxChange(option.value)}
/>
<Label label={option.text} for="{option.value}-checkbox" size="small" />
+2 -2
View File
@@ -141,11 +141,11 @@
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')} required="indicator">
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')} required="indicator">
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>