mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 13:07:04 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22c5add816 | |||
| 8cbf0db3b3 | |||
| a225c962de |
@@ -70,7 +70,8 @@
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "NO">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
|
||||
@@ -27,7 +27,6 @@ sealed class BaseAsset {
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
final bool isFavorite;
|
||||
final String? livePhotoVideoId;
|
||||
final bool isEdited;
|
||||
|
||||
const BaseAsset({
|
||||
@@ -40,32 +39,15 @@ sealed class BaseAsset {
|
||||
this.height,
|
||||
this.durationMs,
|
||||
this.isFavorite = false,
|
||||
this.livePhotoVideoId,
|
||||
required this.isEdited,
|
||||
});
|
||||
|
||||
bool get isImage => type == AssetType.image;
|
||||
bool get isVideo => type == AssetType.video;
|
||||
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
|
||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
if (isMotionPhoto) {
|
||||
return AssetPlaybackStyle.livePhoto;
|
||||
}
|
||||
if (isImage && durationMs != null && durationMs! > 0) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
if (isImage) {
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
Duration get duration {
|
||||
final durationMs = this.durationMs;
|
||||
if (durationMs != null) {
|
||||
@@ -86,6 +68,7 @@ sealed class BaseAsset {
|
||||
String? get localId;
|
||||
String? get remoteId;
|
||||
String get heroTag;
|
||||
AssetPlaybackStyle get playbackStyle;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset {
|
||||
super.height,
|
||||
super.durationMs,
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
|
||||
@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
|
||||
final AssetVisibility visibility;
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final String? livePhotoVideoId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
@@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
this.thumbHash,
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
this.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
@@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
String? get localId => localAssetId;
|
||||
|
||||
@override
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
if (livePhotoVideoId != null) {
|
||||
return AssetPlaybackStyle.livePhoto;
|
||||
}
|
||||
if (isImage && durationMs != null && durationMs! > 0) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
if (isImage) {
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get remoteId => id;
|
||||
|
||||
@@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
livePhotoVideoId == other.livePhotoVideoId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
}
|
||||
@@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ class AssetService {
|
||||
return _localRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class StorageRepository {
|
||||
return file;
|
||||
}
|
||||
|
||||
// TODO(agg23): Unify these methods
|
||||
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
@@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
_PropertyItem(label: 'Height', value: asset.height?.toString()),
|
||||
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
|
||||
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
|
||||
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
||||
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
|
||||
]);
|
||||
}
|
||||
@@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
|
||||
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
|
||||
_PropertyItem(label: 'Stack ID', value: asset.stackId),
|
||||
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
||||
];
|
||||
|
||||
properties.insertAll(4, additionalProps);
|
||||
|
||||
@@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
);
|
||||
}
|
||||
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
|
||||
final localAsset = await _localPlaybackAsset(videoAsset);
|
||||
|
||||
if (localAsset != null) {
|
||||
final file = localAsset.isMotionPhoto
|
||||
? await StorageRepository().getMotionFileForAsset(localAsset)
|
||||
: await StorageRepository().getFileForAsset(localAsset.id);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
@@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
);
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
final remoteAsset = videoAsset as RemoteAsset;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
|
||||
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
|
||||
|
||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||
} catch (error) {
|
||||
@@ -157,6 +161,43 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
|
||||
if (!baseAsset.hasLocal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalAsset? localAsset;
|
||||
|
||||
if (baseAsset is LocalAsset) {
|
||||
localAsset = baseAsset;
|
||||
} else {
|
||||
final localId = (baseAsset as RemoteAsset).localId;
|
||||
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
|
||||
}
|
||||
|
||||
if (localAsset == null) {
|
||||
_log.severe(
|
||||
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
|
||||
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
|
||||
// If this scenario occurs, fall back to using the remote asset
|
||||
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
|
||||
// Platform mismatch for motion photo, use remote instead
|
||||
_log.warning(
|
||||
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return localAsset;
|
||||
}
|
||||
|
||||
void _onPlaybackReady() async {
|
||||
if (!mounted || !widget.isCurrent) {
|
||||
return;
|
||||
|
||||
@@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
|
||||
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
|
||||
final isLivePhoto = asset.isMotionPhoto;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -85,6 +85,7 @@ class ApiService {
|
||||
|
||||
// Save in local database for next startup
|
||||
await Store.put(StoreKey.serverEndpoint, endpoint);
|
||||
await updateHeaders();
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
@@ -173,8 +174,14 @@ class ApiService {
|
||||
|
||||
static List<String> getServerUrls() {
|
||||
final urls = <String>[];
|
||||
// Include the original server URL so the native side can use it
|
||||
// to sync the auth token to other external hosts
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
urls.add(serverUrl);
|
||||
}
|
||||
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty && serverEndpoint != serverUrl) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final network = SettingsRepository.instance.appConfig.network;
|
||||
|
||||
@@ -70,4 +70,36 @@ void main() {
|
||||
expect((assets.first as RemoteAsset).id, asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
group('live photos', () {
|
||||
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
|
||||
final user = await ctx.newUser();
|
||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
|
||||
|
||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
||||
|
||||
expect(assets, hasLength(1));
|
||||
final remote = assets.single as RemoteAsset;
|
||||
expect(remote.id, asset.id);
|
||||
expect(remote.livePhotoVideoId, 'motion-photo-1');
|
||||
expect(remote.isMotionPhoto, isTrue);
|
||||
expect(remote.localId, isNull);
|
||||
});
|
||||
|
||||
test('merged live photo resolves localId and is marked as a motion photo', () async {
|
||||
final user = await ctx.newUser();
|
||||
const checksum = 'shared-live-photo-checksum';
|
||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
|
||||
final local = await ctx.newLocalAsset(checksum: checksum);
|
||||
|
||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
||||
|
||||
expect(assets, hasLength(1));
|
||||
final remote = assets.single as RemoteAsset;
|
||||
expect(remote.id, asset.id);
|
||||
expect(remote.livePhotoVideoId, 'motion-photo-2');
|
||||
expect(remote.isMotionPhoto, isTrue);
|
||||
expect(remote.localId, local.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user