asset.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import 'dart:convert';
  2. import 'package:immich_mobile/shared/models/exif_info.dart';
  3. import 'package:immich_mobile/shared/models/store.dart';
  4. import 'package:immich_mobile/utils/hash.dart';
  5. import 'package:isar/isar.dart';
  6. import 'package:openapi/api.dart';
  7. import 'package:photo_manager/photo_manager.dart';
  8. import 'package:immich_mobile/utils/builtin_extensions.dart';
  9. import 'package:path/path.dart' as p;
  10. part 'asset.g.dart';
  11. /// Asset (online or local)
  12. @Collection(inheritance: false)
  13. class Asset {
  14. Asset.remote(AssetResponseDto remote)
  15. : remoteId = remote.id,
  16. checksum = remote.checksum,
  17. fileCreatedAt = remote.fileCreatedAt,
  18. fileModifiedAt = remote.fileModifiedAt,
  19. updatedAt = remote.updatedAt,
  20. durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
  21. type = remote.type.toAssetType(),
  22. fileName = p.basename(remote.originalPath),
  23. height = remote.exifInfo?.exifImageHeight?.toInt(),
  24. width = remote.exifInfo?.exifImageWidth?.toInt(),
  25. livePhotoVideoId = remote.livePhotoVideoId,
  26. ownerId = fastHash(remote.ownerId),
  27. exifInfo =
  28. remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
  29. isFavorite = remote.isFavorite,
  30. isArchived = remote.isArchived,
  31. thumbhash = remote.thumbhash;
  32. Asset.local(AssetEntity local, List<int> hash)
  33. : localId = local.id,
  34. checksum = base64.encode(hash),
  35. durationInSeconds = local.duration,
  36. type = AssetType.values[local.typeInt],
  37. height = local.height,
  38. width = local.width,
  39. fileName = local.title!,
  40. ownerId = Store.get(StoreKey.currentUser).isarId,
  41. fileModifiedAt = local.modifiedDateTime,
  42. updatedAt = local.modifiedDateTime,
  43. isFavorite = local.isFavorite,
  44. isArchived = false,
  45. fileCreatedAt = local.createDateTime {
  46. if (fileCreatedAt.year == 1970) {
  47. fileCreatedAt = fileModifiedAt;
  48. }
  49. if (local.latitude != null) {
  50. exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
  51. }
  52. _local = local;
  53. assert(hash.length == 20, "invalid SHA1 hash");
  54. }
  55. Asset({
  56. this.id = Isar.autoIncrement,
  57. required this.checksum,
  58. this.remoteId,
  59. required this.localId,
  60. required this.ownerId,
  61. required this.fileCreatedAt,
  62. required this.fileModifiedAt,
  63. required this.updatedAt,
  64. required this.durationInSeconds,
  65. required this.type,
  66. this.width,
  67. this.height,
  68. required this.fileName,
  69. this.livePhotoVideoId,
  70. this.exifInfo,
  71. required this.isFavorite,
  72. required this.isArchived,
  73. required this.thumbhash,
  74. });
  75. @ignore
  76. AssetEntity? _local;
  77. @ignore
  78. AssetEntity? get local {
  79. if (isLocal && _local == null) {
  80. _local = AssetEntity(
  81. id: localId!,
  82. typeInt: isImage ? 1 : 2,
  83. width: width ?? 0,
  84. height: height ?? 0,
  85. duration: durationInSeconds,
  86. createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
  87. modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
  88. title: fileName,
  89. );
  90. }
  91. return _local;
  92. }
  93. Id id = Isar.autoIncrement;
  94. /// stores the raw SHA1 bytes as a base64 String
  95. /// because Isar cannot sort lists of byte arrays
  96. @Index(
  97. unique: true,
  98. replace: false,
  99. type: IndexType.hash,
  100. composite: [CompositeIndex("ownerId")],
  101. )
  102. String checksum;
  103. String? thumbhash;
  104. @Index(unique: false, replace: false, type: IndexType.hash)
  105. String? remoteId;
  106. @Index(unique: false, replace: false, type: IndexType.hash)
  107. String? localId;
  108. int ownerId;
  109. DateTime fileCreatedAt;
  110. DateTime fileModifiedAt;
  111. DateTime updatedAt;
  112. int durationInSeconds;
  113. @Enumerated(EnumType.ordinal)
  114. AssetType type;
  115. short? width;
  116. short? height;
  117. String fileName;
  118. String? livePhotoVideoId;
  119. bool isFavorite;
  120. bool isArchived;
  121. @ignore
  122. ExifInfo? exifInfo;
  123. /// `true` if this [Asset] is present on the device
  124. @ignore
  125. bool get isLocal => localId != null;
  126. @ignore
  127. bool get isInDb => id != Isar.autoIncrement;
  128. @ignore
  129. String get name => p.withoutExtension(fileName);
  130. /// `true` if this [Asset] is present on the server
  131. @ignore
  132. bool get isRemote => remoteId != null;
  133. @ignore
  134. bool get isImage => type == AssetType.image;
  135. @ignore
  136. AssetState get storage {
  137. if (isRemote && isLocal) {
  138. return AssetState.merged;
  139. } else if (isRemote) {
  140. return AssetState.remote;
  141. } else if (isLocal) {
  142. return AssetState.local;
  143. } else {
  144. throw Exception("Asset has illegal state: $this");
  145. }
  146. }
  147. @ignore
  148. Duration get duration => Duration(seconds: durationInSeconds);
  149. @override
  150. bool operator ==(other) {
  151. if (other is! Asset) return false;
  152. return id == other.id &&
  153. checksum == other.checksum &&
  154. thumbhash == other.thumbhash &&
  155. remoteId == other.remoteId &&
  156. localId == other.localId &&
  157. ownerId == other.ownerId &&
  158. fileCreatedAt.isAtSameMomentAs(other.fileCreatedAt) &&
  159. fileModifiedAt.isAtSameMomentAs(other.fileModifiedAt) &&
  160. updatedAt.isAtSameMomentAs(other.updatedAt) &&
  161. durationInSeconds == other.durationInSeconds &&
  162. type == other.type &&
  163. width == other.width &&
  164. height == other.height &&
  165. fileName == other.fileName &&
  166. livePhotoVideoId == other.livePhotoVideoId &&
  167. isFavorite == other.isFavorite &&
  168. isLocal == other.isLocal &&
  169. isArchived == other.isArchived;
  170. }
  171. @override
  172. @ignore
  173. int get hashCode =>
  174. id.hashCode ^
  175. checksum.hashCode ^
  176. thumbhash.hashCode ^
  177. remoteId.hashCode ^
  178. localId.hashCode ^
  179. ownerId.hashCode ^
  180. fileCreatedAt.hashCode ^
  181. fileModifiedAt.hashCode ^
  182. updatedAt.hashCode ^
  183. durationInSeconds.hashCode ^
  184. type.hashCode ^
  185. width.hashCode ^
  186. height.hashCode ^
  187. fileName.hashCode ^
  188. livePhotoVideoId.hashCode ^
  189. isFavorite.hashCode ^
  190. isLocal.hashCode ^
  191. isArchived.hashCode;
  192. /// Returns `true` if this [Asset] can updated with values from parameter [a]
  193. bool canUpdate(Asset a) {
  194. assert(isInDb);
  195. assert(checksum == a.checksum);
  196. assert(thumbhash == a.thumbhash);
  197. assert(a.storage != AssetState.merged);
  198. return a.updatedAt.isAfter(updatedAt) ||
  199. a.isRemote && !isRemote ||
  200. a.isLocal && !isLocal ||
  201. width == null && a.width != null ||
  202. height == null && a.height != null ||
  203. livePhotoVideoId == null && a.livePhotoVideoId != null ||
  204. !isRemote && a.isRemote && isFavorite != a.isFavorite ||
  205. !isRemote && a.isRemote && isArchived != a.isArchived;
  206. }
  207. /// Returns a new [Asset] with values from this and merged & updated with [a]
  208. Asset updatedCopy(Asset a) {
  209. assert(canUpdate(a));
  210. if (a.updatedAt.isAfter(updatedAt)) {
  211. // take most values from newer asset
  212. // keep vales that can never be set by the asset not in DB
  213. if (a.isRemote) {
  214. return a._copyWith(
  215. id: id,
  216. localId: localId,
  217. width: a.width ?? width,
  218. height: a.height ?? height,
  219. exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
  220. );
  221. } else if (isRemote) {
  222. return _copyWith(
  223. localId: localId ?? a.localId,
  224. width: width ?? a.width,
  225. height: height ?? a.height,
  226. exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
  227. );
  228. } else {
  229. return a._copyWith(
  230. id: id,
  231. remoteId: remoteId,
  232. livePhotoVideoId: livePhotoVideoId,
  233. isFavorite: isFavorite,
  234. isArchived: isArchived,
  235. );
  236. }
  237. } else {
  238. // fill in potentially missing values, i.e. merge assets
  239. if (a.isRemote) {
  240. // values from remote take precedence
  241. return _copyWith(
  242. remoteId: a.remoteId,
  243. width: a.width,
  244. height: a.height,
  245. livePhotoVideoId: a.livePhotoVideoId,
  246. // isFavorite + isArchived are not set by device-only assets
  247. isFavorite: a.isFavorite,
  248. isArchived: a.isArchived,
  249. exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
  250. );
  251. } else {
  252. // add only missing values (and set isLocal to true)
  253. return _copyWith(
  254. localId: localId ?? a.localId,
  255. width: width ?? a.width,
  256. height: height ?? a.height,
  257. exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
  258. );
  259. }
  260. }
  261. }
  262. Asset _copyWith({
  263. Id? id,
  264. String? checksum,
  265. String? thumbhash,
  266. String? remoteId,
  267. String? localId,
  268. int? ownerId,
  269. DateTime? fileCreatedAt,
  270. DateTime? fileModifiedAt,
  271. DateTime? updatedAt,
  272. int? durationInSeconds,
  273. AssetType? type,
  274. short? width,
  275. short? height,
  276. String? fileName,
  277. String? livePhotoVideoId,
  278. bool? isFavorite,
  279. bool? isArchived,
  280. ExifInfo? exifInfo,
  281. }) =>
  282. Asset(
  283. id: id ?? this.id,
  284. checksum: checksum ?? this.checksum,
  285. thumbhash: thumbhash ?? this.thumbhash,
  286. remoteId: remoteId ?? this.remoteId,
  287. localId: localId ?? this.localId,
  288. ownerId: ownerId ?? this.ownerId,
  289. fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt,
  290. fileModifiedAt: fileModifiedAt ?? this.fileModifiedAt,
  291. updatedAt: updatedAt ?? this.updatedAt,
  292. durationInSeconds: durationInSeconds ?? this.durationInSeconds,
  293. type: type ?? this.type,
  294. width: width ?? this.width,
  295. height: height ?? this.height,
  296. fileName: fileName ?? this.fileName,
  297. livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
  298. isFavorite: isFavorite ?? this.isFavorite,
  299. isArchived: isArchived ?? this.isArchived,
  300. exifInfo: exifInfo ?? this.exifInfo,
  301. );
  302. Future<void> put(Isar db) async {
  303. await db.assets.put(this);
  304. if (exifInfo != null) {
  305. exifInfo!.id = id;
  306. await db.exifInfos.put(exifInfo!);
  307. }
  308. }
  309. static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
  310. static int compareByChecksum(Asset a, Asset b) =>
  311. a.checksum.compareTo(b.checksum);
  312. static int compareByOwnerChecksum(Asset a, Asset b) {
  313. final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
  314. if (ownerIdOrder != 0) return ownerIdOrder;
  315. return compareByChecksum(a, b);
  316. }
  317. static int compareByOwnerChecksumCreatedModified(Asset a, Asset b) {
  318. final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
  319. if (ownerIdOrder != 0) return ownerIdOrder;
  320. final int checksumOrder = compareByChecksum(a, b);
  321. if (checksumOrder != 0) return checksumOrder;
  322. final int createdOrder = a.fileCreatedAt.compareTo(b.fileCreatedAt);
  323. if (createdOrder != 0) return createdOrder;
  324. return a.fileModifiedAt.compareTo(b.fileModifiedAt);
  325. }
  326. @override
  327. String toString() {
  328. return """
  329. {
  330. "id": ${id == Isar.autoIncrement ? '"N/A"' : id},
  331. "remoteId": "${remoteId ?? "N/A"}",
  332. "localId": "${localId ?? "N/A"}",
  333. "checksum": "$checksum",
  334. "thumbhash": "$thumbhash",
  335. "ownerId": $ownerId,
  336. "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
  337. "fileCreatedAt": "$fileCreatedAt",
  338. "fileModifiedAt": "$fileModifiedAt",
  339. "updatedAt": "$updatedAt",
  340. "durationInSeconds": $durationInSeconds,
  341. "type": "$type",
  342. "fileName": "$fileName",
  343. "isFavorite": $isFavorite,
  344. "isRemote: $isRemote,
  345. "storage": "$storage",
  346. "width": ${width ?? "N/A"},
  347. "height": ${height ?? "N/A"},
  348. "isArchived": $isArchived
  349. }""";
  350. }
  351. }
  352. enum AssetType {
  353. // do not change this order!
  354. other,
  355. image,
  356. video,
  357. audio,
  358. }
  359. extension AssetTypeEnumHelper on AssetTypeEnum {
  360. AssetType toAssetType() {
  361. switch (this) {
  362. case AssetTypeEnum.IMAGE:
  363. return AssetType.image;
  364. case AssetTypeEnum.VIDEO:
  365. return AssetType.video;
  366. case AssetTypeEnum.AUDIO:
  367. return AssetType.audio;
  368. case AssetTypeEnum.OTHER:
  369. return AssetType.other;
  370. }
  371. throw Exception();
  372. }
  373. }
  374. /// Describes where the information of this asset came from:
  375. /// only from the local device, only from the remote server or merged from both
  376. enum AssetState {
  377. local,
  378. remote,
  379. merged,
  380. }
  381. extension AssetsHelper on IsarCollection<Asset> {
  382. Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
  383. ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
  384. Future<int> deleteAllByLocalId(Iterable<String> ids) =>
  385. ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
  386. Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
  387. ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
  388. Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
  389. ids.isEmpty ? Future.value([]) : _local(ids).findAll();
  390. QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
  391. where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
  392. QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
  393. return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
  394. }
  395. }