Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
99a8740f1b only migrate for images 2026-01-27 22:37:12 -06:00
Alex
a781c78caf fix: width and height migration issue 2026-01-27 21:48:19 -06:00
33 changed files with 180 additions and 535 deletions

View File

@@ -1,116 +0,0 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
const buildSearchUrl = (assetId: string) => {
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
return `/search/photos/${assetId}?query=${searchQuery}`;
};
test.describe.configure({ mode: 'parallel' });
test.describe('search gallery-viewer', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {
total: searchAssets.length,
count: searchAssets.length,
items: searchAssets,
facets: [],
nextPage: null,
},
},
});
}
await route.fallback();
});
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/search/photos/:id', () => {
test('Deleting a photo advances to the next photo', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
});
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
const asset = assets[1];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
const lastAsset = assets[4];
await page.goto(buildSearchUrl(lastAsset.id));
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
await expect(page.getByLabel('View next asset')).toHaveCount(0);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
await expect(page.getByLabel('View previous asset')).toBeVisible();
});
});
});

View File

@@ -75,7 +75,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
pnpm --prefix server run build
( cd ./open-api && bash ./bin/generate-open-api.sh )
uv version --directory machine-learning "$NEXT_SERVER"
uvx --from=toml-cli toml set --toml-path=machine-learning/pyproject.toml project.version "$NEXT_SERVER"
./misc/release/archive-version.js "$NEXT_SERVER"
fi

View File

@@ -1,18 +1,5 @@
experimental_monorepo_root = true
[monorepo]
config_roots = [
"plugins",
"server",
"cli",
"deployment",
"mobile",
"e2e",
"web",
"docs",
".github",
]
[tools]
node = "24.13.0"
flutter = "3.35.7"

View File

@@ -741,7 +741,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -885,7 +885,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -915,7 +915,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -949,7 +949,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -992,7 +992,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1032,7 +1032,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1071,7 +1071,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1115,7 +1115,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1156,7 +1156,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -79,7 +79,6 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
kCGImageSourceShouldCache: false,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary
func urlSession(

View File

@@ -107,7 +107,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>240</string>
<string>233</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -39,14 +39,6 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -89,9 +89,7 @@ enum StoreKey<T> {
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012),
syncMigrationStatus<String>._(1013);
cleanupDefaultsInitialized<bool>._(1012);
const StoreKey._(this.id);
final int id;

View File

@@ -98,7 +98,12 @@ class AssetService {
height = fetched?.height?.toDouble();
}
return (width: width, height: height, isFlipped: false);
// Check exif for orientation to determine if dimensions should be flipped
// This is important for videos where raw file dimensions may not match display dimensions
final exif = await _remoteAssetRepository.getExif(asset.id);
final isFlipped = exif?.isFlipped ?? false;
return (width: width, height: height, isFlipped: isFlipped);
}
Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -1,7 +1,4 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
@@ -10,21 +7,12 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
enum SyncMigrationTask {
v20260128_ResetExifV1, // EXIF table has incorrect width and height information.
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
}
class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
@@ -34,8 +22,6 @@ class SyncStreamService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
SyncStreamService({
@@ -45,8 +31,6 @@ class SyncStreamService {
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
@@ -54,32 +38,12 @@ class SyncStreamService {
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
bool get isCancelled => _cancelChecker?.call() ?? false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
final serverVersion = await _api.serverInfoApi.getServerVersion();
if (serverVersion == null) {
_logger.severe("Cannot perform sync: unable to determine server version");
return false;
}
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, semVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
@@ -87,56 +51,9 @@ class SyncStreamService {
_logger.info("Resetting sync state as requested by server");
await _syncApiRepository.streamChanges(_handleEvents);
}
previousLength = migrations.length;
await _runPostSyncTasks(migrations);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
}
return true;
}
Future<void> _runPreSyncTasks(List<String> migrations, SemVer semVer) async {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
}
Future<void> _runPostSyncTasks(List<String> migrations) async {
if (!migrations.contains(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name)) {
_logger.info("Running post-sync task: v20260128_CopyExifWidthHeightToAsset");
await _syncMigrationRepository.v20260128CopyExifWidthHeightToAsset();
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
}
}
Future<void> _handleEvents(List<SyncEvent> events, Function() abort, Function() reset) async {
List<SyncEvent> items = [];
for (final event in events) {

View File

@@ -144,13 +144,16 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) async {
final iosSharedAlbumIds = _db.localAlbumEntity.selectOnly()
..addColumns([_db.localAlbumEntity.id])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.isInQuery(iosSharedAlbumIds));
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true));
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),

View File

@@ -19,10 +19,6 @@ class SyncApiRepository {
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
}
Future<void> deleteSyncAck(List<SyncEntityType> types) {
return _api.syncApi.deleteSyncAck(SyncAckDeleteDto(types: types));
}
Future<void> streamChanges(
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
Function()? onReset,

View File

@@ -1,24 +0,0 @@
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SyncMigrationRepository extends DriftDatabaseRepository {
final Drift _db;
const SyncMigrationRepository(super.db) : _db = db;
Future<void> v20260128CopyExifWidthHeightToAsset() async {
await _db.customStatement('''
UPDATE remote_asset_entity
SET width = CASE
WHEN exif.orientation IN ('5', '6', '7', '8', '-90', '90') THEN exif.height
ELSE exif.width
END,
height = CASE
WHEN exif.orientation IN ('5', '6', '7', '8', '-90', '90') THEN exif.width
ELSE exif.height
END
FROM remote_exif_entity exif
WHERE exif.asset_id = remote_asset_entity.id
AND (exif.width IS NOT NULL OR exif.height IS NOT NULL);
''');
}
}

View File

@@ -92,9 +92,7 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
// Hide controls by default for videos
if (asset.isVideo) {
// Hide controls by default for videos and motion photos
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
@@ -149,11 +147,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
if (ref.read(assetViewerProvider).showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
}
@override

View File

@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -14,8 +13,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
@@ -24,8 +21,6 @@ final syncStreamServiceProvider = Provider(
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
),
);

View File

@@ -28,7 +28,6 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
@@ -89,6 +88,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
await _backfillAssetExifWidthHeight(drift);
}
if (targetVersion >= 12) {
@@ -282,6 +282,26 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
}
}
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
try {
// Only backfill images (type = 1), not videos
// Videos have different dimension handling based on orientation/exif
await db.customStatement('''
UPDATE remote_exif_entity AS remote_exif
SET width = asset.width,
height = asset.height
FROM remote_asset_entity AS asset
WHERE remote_exif.asset_id = asset.id
AND asset.type = 1
AND (remote_exif.width IS NULL OR remote_exif.width = 0 OR remote_exif.height IS NULL OR remote_exif.height = 0);
''');
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
} catch (error) {
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

View File

@@ -4,5 +4,3 @@ import 'package:openapi/api.dart';
class MockAssetsApi extends Mock implements AssetsApi {}
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}

View File

@@ -19,15 +19,12 @@ import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
class _AbortCallbackWrapper {
const _AbortCallbackWrapper();
@@ -53,9 +50,6 @@ void main() {
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
@@ -88,9 +82,6 @@ void main() {
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
mockServerApi = MockServerApi();
mockSyncMigrationRepo = MockSyncMigrationRepository();
when(() => mockAbortCallbackWrapper()).thenReturn(false);
@@ -103,12 +94,6 @@ void main() {
});
when(() => mockSyncApiRepo.ack(any())).thenAnswer((_) async => {});
when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {});
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(() => mockServerApi.getServerVersion()).thenAnswer(
(_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0),
);
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -142,7 +127,6 @@ void main() {
when(() => mockSyncStreamRepo.deletePeopleV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
when(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset()).thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
@@ -151,8 +135,6 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
@@ -234,8 +216,6 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
await sut.sync();
@@ -275,8 +255,6 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
await sut.sync();
@@ -496,7 +474,11 @@ void main() {
});
final events = [
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
SyncStreamStub.assetModified(
id: 'remote-1',
checksum: 'checksum-trash',
ack: 'asset-remote-1-11',
),
];
await simulateEvents(events);
@@ -504,75 +486,4 @@ void main() {
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
});
});
group('SyncStreamService - Sync Migration', () {
test('ensure that <2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
verifyInOrder([
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]),
() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset(),
]);
// should only run on server >2.5.0
verifyNever(
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]),
);
});
test('ensure that >=2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
await sut.sync();
verifyInOrder([
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
]),
() => mockSyncApiRepo.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]),
]);
// v20260128_ResetAssetV1 writes that v20260128_CopyExifWidthHeightToAsset has been completed
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
});
test('ensure that migrations do not re-run', () async {
await Store.put(
StoreKey.syncMigrationStatus,
'["${SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name}"]',
);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
});
});
}

View File

@@ -438,39 +438,6 @@ void main() {
expect(result.assets, isEmpty);
});
test('excludes user-owned assets in iOS shared albums', () async {
await insertLocalAlbum(id: 'album-recents', name: 'Recents', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true);
await insertLocalAsset(
id: 'local-user-shared',
checksum: 'checksum-user-shared',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-user-shared', checksum: 'checksum-user-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-recents', assetId: 'local-user-shared');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-user-shared');
// User's asset only in Recents (not in iOS shared album) - should be included for removal
await insertLocalAsset(
id: 'local-user-regular',
checksum: 'checksum-user-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-user-regular', checksum: 'checksum-user-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-recents', assetId: 'local-user-regular');
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-user-regular');
});
test('excludes assets with null checksum (not backed up)', () async {
// Asset with null checksum cannot be matched to remote asset
await db

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
@@ -47,8 +46,6 @@ class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
class MockUploadRepository extends Mock implements UploadRepository {}
class MockSyncMigrationRepository extends Mock implements SyncMigrationRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}

View File

@@ -118,7 +118,13 @@ abstract final class TestUtils {
return result;
}
static domain.RemoteAsset createRemoteAsset({required String id, int? width, int? height, String? ownerId}) {
static domain.RemoteAsset createRemoteAsset({
required String id,
int? width,
int? height,
String? ownerId,
String? localId,
}) {
return domain.RemoteAsset(
id: id,
checksum: 'checksum1',
@@ -132,6 +138,7 @@ abstract final class TestUtils {
width: width,
height: height,
isEdited: false,
localId: localId,
);
}

View File

@@ -79,7 +79,7 @@ export class MaintenanceWorkerService {
this.#secret = state.secret;
this.#status = {
active: true,
action: state.action?.action ?? MaintenanceAction.Start,
action: state.action.action,
};
StorageCore.setMediaLocation(this.detectMediaLocation());
@@ -88,10 +88,7 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
await this.logSecret();
if (state.action) {
void this.runAction(state.action);
}
void this.runAction(state.action);
}
/**

View File

@@ -490,7 +490,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState =
| { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto }
| { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto }
| { isMaintenanceMode: false };
export type MemoriesState = {
/** memories have already been created through this date */

View File

@@ -302,7 +302,6 @@
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
@@ -310,8 +309,10 @@
if (assets.length === 0) {
return await goto(Route.photos());
}
if (nextAsset) {
await navigateToAsset(nextAsset);
if (assetCursor.nextAsset) {
await navigateToAsset(assetCursor.nextAsset);
} else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
}
break;
}

View File

@@ -97,6 +97,7 @@
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });

View File

@@ -141,41 +141,43 @@
}
};
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
});
return shortcuts;
})(),
);
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />

View File

@@ -3,13 +3,13 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import { user } from '$lib/stores/user.store';
import {
AlbumUserRole,
AssetOrder,
@@ -56,7 +56,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
@@ -108,9 +108,9 @@
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={album.owner} size="md" />
<UserAvatar user={$user} size="md" />
</div>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Text class="w-full" size="small">{$user.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>

View File

@@ -1,6 +1,5 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -26,7 +25,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -40,7 +39,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -67,16 +66,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, v
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
return { Share, AddUsers, CreateSharedLink };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange, timeToSeconds } from './date-time';
import { getAlbumDateRange, timeToSeconds } from './date-time';
describe('converting time to seconds', () => {
it('parses hh:mm:ss correctly', () => {
@@ -49,43 +49,6 @@ describe('converting time to seconds', () => {
});
});
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
});
afterAll(() => {
vi.unstubAllEnvs();
});
it('should correctly return month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
});
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
});
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';

View File

@@ -19,30 +19,28 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
}
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
startDate = startDate instanceof Date ? startDate : new Date(startDate);
endDate = endDate instanceof Date ? endDate : new Date(endDate);
const userLocale = get(locale);
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (userLocale) {
startDate = startDate.setLocale(userLocale);
endDate = endDate.setLocale(userLocale);
}
const endDateLocalized = endDate.toLocaleString({
const endDateLocalized = endDate.toLocaleString(userLocale, {
month: 'short',
year: 'numeric',
// The API returns the date in UTC. If the earliest asset was taken on Jan 1st at 1am,
// we expect the album to start in January, even if the local timezone is UTC-5 for instance.
timeZone: 'UTC',
});
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
if (startDate.getFullYear() === endDate.getFullYear()) {
if (startDate.getMonth() === endDate.getMonth()) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString({
const startMonthLocalized = startDate.toLocaleString(userLocale, {
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
@@ -50,7 +48,7 @@ export const getShortDateRange = (startTimestamp: string, endTimestamp: string)
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString({
const startDateLocalized = startDate.toLocaleString(userLocale, {
month: 'short',
year: 'numeric',
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { goto, onNavigate } from '$app/navigation';
import { afterNavigate, goto, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ActionButton from '$lib/components/ActionButton.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
@@ -52,7 +52,13 @@
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import {
isAlbumsRoute,
isPeopleRoute,
isSearchRoute,
navigate,
type AssetGridRouteSearchParams,
} from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
@@ -85,6 +91,7 @@
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let backUrl: string = $state(Route.albums());
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -93,6 +100,25 @@
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname;
const route = from?.route?.id;
if (isSearchRoute(route)) {
url = from?.url.href;
}
if (isAlbumsRoute(route) || isPeopleRoute(route)) {
url = Route.albums();
}
backUrl = url || Route.albums();
if (backUrl === Route.sharedLinks()) {
backUrl = history.state?.backUrl || Route.albums();
}
});
const handleFavorite = async () => {
try {
await activityManager.toggleLike();
@@ -132,6 +158,7 @@
cancelMultiselect(assetInteraction);
return;
}
await goto(backUrl);
return;
};
@@ -278,7 +305,7 @@
const onAlbumDelete = async ({ id }: AlbumResponseDto) => {
if (id === album.id) {
await goto(Route.albums());
await goto(backUrl);
viewMode = AlbumPageViewMode.VIEW;
}
};
@@ -305,7 +332,7 @@
};
const { Cast } = $derived(getGlobalActions($t));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { Share } = $derived(getAlbumActions($t, album));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
</script>
@@ -319,7 +346,7 @@
onAlbumUserDelete={refreshAlbum}
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
/>
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close]} />
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
<div class="relative w-full shrink">
@@ -485,7 +512,7 @@
</AssetSelectControlBar>
{:else}
{#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
{#snippet trailing()}
<ActionButton action={Cast} />

View File

@@ -33,6 +33,7 @@
import { Route } from '$lib/route';
import { getPersonActions } from '$lib/services/person.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
@@ -60,6 +61,7 @@
let { data }: Props = $props();
let numberOfAssets = $derived(data.statistics.assets);
let { isViewing: showAssetViewer } = assetViewingStore;
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
@@ -104,13 +106,16 @@
});
const handleEscape = async () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
return;
} else {
await goto(previousRoute);
return;
}
await goto(previousRoute);
return;
};
const updateAssetCount = async () => {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@@ -23,6 +24,7 @@
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -46,6 +48,7 @@
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
@@ -79,6 +82,18 @@
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.selectedAssets = [];
return;
}
handlePromiseError(goto(previousRoute));
};
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
@@ -245,6 +260,7 @@
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
{#if terms}
<section