mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
Compare commits
1 Commits
fix/ios-ha
...
feat/focus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394a37bdd2 |
@@ -271,12 +271,12 @@ class UploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final uploadFileResult = await prepareUploadFile(asset, isLivePhoto: entity.isLivePhoto);
|
||||
if (uploadFileResult == null) {
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final (:file, :originalFilename) = uploadFileResult;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
||||
|
||||
String metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
@@ -290,7 +290,7 @@ class UploadService {
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFilename,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
group: "group",
|
||||
@@ -308,6 +308,8 @@ class UploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
/// They are uploaded separately, with video file being upload first, then returned with the assetId
|
||||
/// The assetId is then used as a metadata for the photo file upload task.
|
||||
@@ -318,12 +320,18 @@ class UploadService {
|
||||
/// The cancel operation will only cancel the video group (normal group), the photo group will not
|
||||
/// be touched, as the video file is already uploaded.
|
||||
|
||||
final uploadFileResult = await prepareUploadFile(asset, isLivePhoto: entity.isLivePhoto);
|
||||
if (uploadFileResult == null) {
|
||||
if (entity.isLivePhoto) {
|
||||
file = await _storageRepository.getMotionFileForAsset(asset);
|
||||
} else {
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final (:file, :originalFilename) = uploadFileResult;
|
||||
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
|
||||
String metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
@@ -337,7 +345,7 @@ class UploadService {
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFilename,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
group: group,
|
||||
@@ -354,20 +362,21 @@ class UploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = await prepareUploadFile(asset);
|
||||
if (result == null) {
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fields = {'livePhotoVideoId': livePhotoVideoId};
|
||||
|
||||
final requiresWiFi = _shouldRequireWiFi(asset);
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
return buildUploadTask(
|
||||
result.file,
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: result.originalFilename,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
fields: fields,
|
||||
group: kBackupLivePhotoGroup,
|
||||
@@ -389,54 +398,6 @@ class UploadService {
|
||||
return requiresWiFi;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<({File file, String originalFilename})?> prepareUploadFile(
|
||||
LocalAsset asset, {
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
final file = isLivePhoto
|
||||
? await _storageRepository.getMotionFileForAsset(asset)
|
||||
: await _storageRepository.getFileForAsset(asset.id);
|
||||
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final originalFilename = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
if (isLivePhoto) {
|
||||
final livePhotoFilename = p.setExtension(originalFilename, p.extension(file.path));
|
||||
return (file: file, originalFilename: livePhotoFilename);
|
||||
}
|
||||
|
||||
final filenameExt = p.extension(originalFilename);
|
||||
if (filenameExt.isNotEmpty) {
|
||||
return (file: file, originalFilename: originalFilename);
|
||||
}
|
||||
|
||||
final assetNameExt = p.extension(asset.name);
|
||||
if (assetNameExt.isNotEmpty) {
|
||||
final correctedFilename = p.setExtension(originalFilename, assetNameExt);
|
||||
_logger.fine(
|
||||
"Corrected filename $originalFilename to $correctedFilename using asset.name extension $assetNameExt",
|
||||
);
|
||||
return (file: file, originalFilename: correctedFilename);
|
||||
}
|
||||
|
||||
final filePathExt = p.extension(file.path);
|
||||
if (filePathExt.isEmpty) {
|
||||
_logger.warning(
|
||||
"Asset ${asset.id} has no file extension in any source, using original filename - $originalFilename",
|
||||
);
|
||||
return (file: file, originalFilename: originalFilename);
|
||||
}
|
||||
|
||||
final correctedFilename = p.setExtension(originalFilename, filePathExt);
|
||||
_logger.fine("Corrected filename $originalFilename to $correctedFilename using file path extension $filePathExt");
|
||||
|
||||
return (file: file, originalFilename: correctedFilename);
|
||||
}
|
||||
|
||||
Future<UploadTask> buildUploadTask(
|
||||
File file, {
|
||||
required String group,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -17,8 +16,8 @@ import 'package:mocktail/mocktail.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
|
||||
void main() {
|
||||
late UploadService sut;
|
||||
@@ -166,174 +165,4 @@ void main() {
|
||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('prepareUploadFile', () {
|
||||
test('should keep filename with existing extension unchanged', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockFile = File('/tmp/123.jpg');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.file.path, equals('/tmp/123.jpg'));
|
||||
expect(result.originalFilename, equals('photo.jpg'));
|
||||
});
|
||||
|
||||
test('should use asset.name extension when original filename lacks one', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockFile = File('/tmp/cache/123.mov');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => '2024-10-23_17-00-30');
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.originalFilename, equals('2024-10-23_17-00-30.jpg'));
|
||||
});
|
||||
|
||||
test('should use file path extension as final fallback', () async {
|
||||
final asset = LocalAssetStub.image1.copyWith(name: 'document');
|
||||
final mockFile = File('/tmp/cache/123.mov');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'document');
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.originalFilename, equals('document.mov'));
|
||||
});
|
||||
|
||||
test('should handle file without extension anywhere', () async {
|
||||
final asset = LocalAssetStub.image1.copyWith(name: 'document');
|
||||
final mockFile = File('/tmp/temp');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'document');
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.originalFilename, equals('document'));
|
||||
});
|
||||
|
||||
test('should preserve existing extension even if asset.name has different one', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockFile = File('/tmp/123.mov');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.HEIC');
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.originalFilename, equals('photo.HEIC'));
|
||||
});
|
||||
|
||||
test('should fall back to asset.name when getOriginalFilename returns null', () async {
|
||||
final asset = LocalAssetStub.image1.copyWith(name: 'VID_1234.mp4');
|
||||
final mockFile = File('/tmp/video.mov');
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.originalFilename, equals('VID_1234.mp4')); // Uses asset.name directly
|
||||
});
|
||||
|
||||
test('should return null when file is not found', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final result = await sut.prepareUploadFile(asset);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('getUploadTask with missing extensions', () {
|
||||
test('should add extension for regular photo without extension', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => '2024-10-23_17-00-30');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('2024-10-23_17-00-30.jpg'));
|
||||
});
|
||||
|
||||
test('should preserve existing extension for regular photo', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/file.jpg');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'MyPhoto.HEIC');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('MyPhoto.HEIC'));
|
||||
});
|
||||
|
||||
test('should add extension for video without extension', () async {
|
||||
// Create a video asset using copyWith since image2 is a video type
|
||||
final asset = LocalAssetStub.image1.copyWith(id: 'video1', name: 'VID_20241023_170030', type: AssetType.video);
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/video.mov');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'VID_20241023_170030');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('VID_20241023_170030.mov'));
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask with missing extensions', () {
|
||||
test('should add extension when live photo filename lacks one', () async {
|
||||
final asset = LocalAssetStub.image1.copyWith(name: 'IMG_1234.heic');
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/photo.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_1234');
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-123');
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('IMG_1234.heic'));
|
||||
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
||||
});
|
||||
|
||||
test('should preserve extension when live photo filename has one', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final mockEntity = MockAssetEntity();
|
||||
final mockFile = File('/path/to/photo.heic');
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'MyLivePhoto.HEIC');
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.fields['filename'], equals('MyLivePhoto.HEIC'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
@@ -25,7 +26,7 @@
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
@@ -226,6 +227,9 @@
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
timelineManager.scrollTo(0);
|
||||
} else if (scrollTarget) {
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
};
|
||||
|
||||
@@ -21,11 +21,15 @@ export const focusPreviousAsset = () =>
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const focusAsset = (assetId: string) => {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
element?.focus();
|
||||
};
|
||||
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
focusAsset(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,8 +75,7 @@ export const setFocusTo = async (
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
focusAsset(asset.id);
|
||||
}
|
||||
|
||||
invocation.endInvocation();
|
||||
|
||||
Reference in New Issue
Block a user