Compare commits
1 Commits
refactor/t
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7e4e55489 |
6
.github/workflows/build-mobile.yml
vendored
6
.github/workflows/build-mobile.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
mobile:
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
|
||||
2
.github/workflows/docs-build.yml
vendored
2
.github/workflows/docs-build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/merge-translations.yml
vendored
2
.github/workflows/merge-translations.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
mobile:
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
i18n:
|
||||
|
||||
2
.github/workflows/weblate-lock.yml
vendored
2
.github/workflows/weblate-lock.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check what should run
|
||||
id: check
|
||||
uses: immich-app/devtools/actions/pre-job@5f91b52dfbb92b8d96ca411ab59c896cd59714ca # pre-job-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/pre-job@24820aa4ef67959b0dcf69a438cccf00d7c7042b # pre-job-action-v1.0.1
|
||||
with:
|
||||
filters: |
|
||||
i18n:
|
||||
|
||||
@@ -140,7 +140,7 @@ services:
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:c44be5f2871c59362966d71eab4268170eb6f5653c0e6170184e72b38ffdf107
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"add_to_album": "Add to album",
|
||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||
"add_to_album_bottom_sheet_some_local_assets": "Some local assets could not be added to album",
|
||||
"add_to_album_toggle": "Toggle selection for {album}",
|
||||
"add_to_albums": "Add to albums",
|
||||
"add_to_albums_count": "Add to albums ({count})",
|
||||
|
||||
@@ -116,7 +116,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
if (Platform.isAndroid) {
|
||||
await _backgroundHostApi.showNotification(
|
||||
IntlKeys.uploading_media.t(),
|
||||
IntlKeys.backup_background_service_default_notification.t(),
|
||||
IntlKeys.backup_background_service_in_progress_notification.t(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
}
|
||||
|
||||
extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
LocalAsset toDto({String? remoteId}) => LocalAsset(
|
||||
LocalAsset toDto() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: checksum,
|
||||
@@ -32,7 +32,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
isFavorite: isFavorite,
|
||||
height: height,
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
remoteId: null,
|
||||
orientation: orientation,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
||||
}
|
||||
|
||||
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
RemoteAsset toDto({String? localId}) => RemoteAsset(
|
||||
RemoteAsset toDto() => RemoteAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
ownerId: ownerId,
|
||||
@@ -64,7 +64,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
thumbHash: thumbHash,
|
||||
visibility: visibility,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
localId: null,
|
||||
stackId: stackId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,9 +148,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
return query
|
||||
.map((row) => row.readTable(_db.localAssetEntity).toDto(remoteId: row.read(_db.remoteAssetEntity.id)))
|
||||
.get();
|
||||
return query.map((row) {
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
||||
}).get();
|
||||
}
|
||||
|
||||
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
|
||||
@@ -164,15 +165,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
.count(where: (row) => row.albumId.equals(albumId))
|
||||
.map(_generateBuckets)
|
||||
.watch()
|
||||
.map((results) => results.isNotEmpty ? results.first : const <Bucket>[])
|
||||
.handleError((error) => const <Bucket>[]);
|
||||
.map((results) => results.isNotEmpty ? results.first : <Bucket>[])
|
||||
.handleError((error) {
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId)))
|
||||
.watch()
|
||||
.switchMap((albums) {
|
||||
if (albums.isEmpty) {
|
||||
return Stream.value(const <Bucket>[]);
|
||||
return Stream.value(<Bucket>[]);
|
||||
}
|
||||
|
||||
final album = albums.first;
|
||||
@@ -204,8 +207,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return TimeBucket(date: timeline, assetCount: assetCount);
|
||||
}).watch();
|
||||
})
|
||||
// If there's an error (e.g., album was deleted), return empty buckets
|
||||
.handleError((error) => const <Bucket>[]);
|
||||
.handleError((error) {
|
||||
// If there's an error (e.g., album was deleted), return empty buckets
|
||||
return <Bucket>[];
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
|
||||
@@ -213,22 +218,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
|
||||
// If album doesn't exist (was deleted), return empty list
|
||||
if (albumData == null) {
|
||||
return const <BaseAsset>[];
|
||||
return <BaseAsset>[];
|
||||
}
|
||||
|
||||
final isAscending = albumData.order == AlbumAssetOrder.asc;
|
||||
|
||||
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
|
||||
final query = _db.remoteAssetEntity.select().join([
|
||||
innerJoin(
|
||||
_db.remoteAlbumAssetEntity,
|
||||
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
|
||||
|
||||
if (isAscending) {
|
||||
@@ -239,14 +239,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
|
||||
query.limit(count, offset: offset);
|
||||
|
||||
return query
|
||||
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
|
||||
.get();
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
TimelineQuery fromAssets(List<BaseAsset> assets) => (
|
||||
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
|
||||
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList(growable: false)),
|
||||
assetSource: (offset, count) => Future.value(assets.skip(offset).take(count).toList()),
|
||||
);
|
||||
|
||||
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
@@ -488,7 +486,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
TimelineQuery _remoteQueryBuilder({
|
||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
@@ -526,7 +523,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}).watch();
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
Future<List<BaseAsset>> _getRemoteAssets({
|
||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||
required int offset,
|
||||
@@ -547,9 +543,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
return query
|
||||
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
|
||||
.get();
|
||||
return query.map((row) {
|
||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
||||
final localId = row.read(_db.localAssetEntity.id);
|
||||
return asset.copyWith(localId: localId);
|
||||
}).get();
|
||||
} else {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(filter)
|
||||
@@ -562,12 +560,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
final buckets = List.filled(
|
||||
(count / kTimelineNoneSegmentSize).ceil(),
|
||||
const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||
final buckets = List.generate(
|
||||
(count / kTimelineNoneSegmentSize).floor(),
|
||||
(_) => const Bucket(assetCount: kTimelineNoneSegmentSize),
|
||||
);
|
||||
if (count % kTimelineNoneSegmentSize != 0) {
|
||||
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
|
||||
buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize));
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
@@ -592,6 +590,10 @@ extension on String {
|
||||
GroupAssetsBy.month => "y-M",
|
||||
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
|
||||
};
|
||||
return DateFormat(format, 'en').parse(this);
|
||||
try {
|
||||
return DateFormat(format, 'en').parse(this);
|
||||
} catch (e) {
|
||||
throw FormatException("Invalid date format: $this", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,9 @@ class DriftMapPage extends StatelessWidget {
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
style: IconButton.styleFrom(
|
||||
shape: const CircleBorder(side: BorderSide(width: 1, color: Colors.black26)),
|
||||
padding: const EdgeInsets.all(8),
|
||||
backgroundColor: Colors.indigo,
|
||||
shadowColor: Colors.black26,
|
||||
elevation: 4,
|
||||
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -36,7 +36,7 @@ class UnStackActionButton extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.layers_clear_outlined,
|
||||
iconData: Icons.filter_none_rounded,
|
||||
label: "unstack".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
|
||||
@@ -51,7 +51,6 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
||||
isArchived: isArchived,
|
||||
isTrashEnabled: isTrashEnable,
|
||||
isInLockedView: isInLockedView,
|
||||
isStacked: asset.hasRemote && (asset as RemoteAsset).stackId != null,
|
||||
currentAlbum: currentAlbum,
|
||||
advancedTroubleshooting: advancedTroubleshooting,
|
||||
source: ActionSource.viewer,
|
||||
|
||||
@@ -57,7 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -45,7 +44,6 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (multiselect.hasLocal) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -45,7 +44,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (multiselect.hasLocal) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
@@ -20,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
@@ -64,19 +62,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
|
||||
final addedCount = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
|
||||
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
|
||||
|
||||
if (selectedAssets.length != remoteAssets.length) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
|
||||
);
|
||||
}
|
||||
|
||||
if (addedCount != remoteAssets.length) {
|
||||
if (addedCount != selectedAssets.length) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
|
||||
@@ -118,18 +108,15 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
const DeleteActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
? [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
]
|
||||
: [],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
@@ -11,14 +10,13 @@ class MapBottomSheet extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
return const BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.9,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
backgroundColor: context.themeData.colorScheme.surface,
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
@@ -103,7 +102,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
if (multiselect.hasLocal) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -123,14 +123,28 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
||||
return provider;
|
||||
}
|
||||
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = kThumbnailResolution}) {
|
||||
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
||||
|
||||
if (remoteId != null) {
|
||||
return RemoteThumbProvider(assetId: remoteId);
|
||||
}
|
||||
|
||||
if (_shouldUseLocalAsset(asset!)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||
}
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
|
||||
final String assetId;
|
||||
if (asset is LocalAsset && asset.hasRemote) {
|
||||
assetId = asset.remoteId!;
|
||||
} else if (asset is RemoteAsset) {
|
||||
assetId = asset.id;
|
||||
} else {
|
||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||
}
|
||||
|
||||
return RemoteThumbProvider(assetId: assetId);
|
||||
}
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
@@ -38,7 +39,14 @@ class Thumbnail extends StatefulWidget {
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
|
||||
imageProvider = switch (asset) {
|
||||
RemoteAsset() =>
|
||||
asset.localId == null
|
||||
? RemoteThumbProvider(assetId: asset.id)
|
||||
: LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type),
|
||||
LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
State<Thumbnail> createState() => _ThumbnailState();
|
||||
|
||||
@@ -54,6 +54,8 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
)
|
||||
: const BoxDecoration();
|
||||
|
||||
final hasStack = asset is RemoteAsset && asset.stackId != null;
|
||||
|
||||
final bool storageIndicator =
|
||||
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
||||
|
||||
@@ -75,10 +77,21 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
child: Thumbnail.fromAsset(asset: asset, size: size),
|
||||
),
|
||||
),
|
||||
if (asset != null)
|
||||
if (hasStack)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: _AssetTypeIcons(asset: asset),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
|
||||
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
),
|
||||
if (asset != null && asset.isVideo)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _VideoIndicator(asset.duration),
|
||||
),
|
||||
),
|
||||
if (storageIndicator && asset != null)
|
||||
switch (asset.storage) {
|
||||
@@ -201,34 +214,3 @@ class _TileOverlayIcon extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTypeIcons extends StatelessWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const _AssetTypeIcons({required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
|
||||
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo)
|
||||
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
|
||||
if (hasStack)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||
),
|
||||
if (isLivePhoto)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -10,8 +9,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -188,8 +187,6 @@ class _Map extends StatelessWidget {
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onMapReady,
|
||||
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -212,14 +212,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
if (fallbackSegment != null) {
|
||||
// Scroll to the segment with a small offset to show the header
|
||||
final targetOffset = fallbackSegment.startOffset - 50;
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(
|
||||
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||
_scrollController.animateTo(
|
||||
targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -11,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
@@ -89,6 +90,33 @@ class DriftUploadStatus {
|
||||
networkSpeedAsString.hashCode ^
|
||||
isFailed.hashCode;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'taskId': taskId,
|
||||
'filename': filename,
|
||||
'progress': progress,
|
||||
'fileSize': fileSize,
|
||||
'networkSpeedAsString': networkSpeedAsString,
|
||||
'isFailed': isFailed,
|
||||
};
|
||||
}
|
||||
|
||||
factory DriftUploadStatus.fromMap(Map<String, dynamic> map) {
|
||||
return DriftUploadStatus(
|
||||
taskId: map['taskId'] as String,
|
||||
filename: map['filename'] as String,
|
||||
progress: map['progress'] as double,
|
||||
fileSize: map['fileSize'] as int,
|
||||
networkSpeedAsString: map['networkSpeedAsString'] as String,
|
||||
isFailed: map['isFailed'] != null ? map['isFailed'] as bool : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory DriftUploadStatus.fromJson(String source) =>
|
||||
DriftUploadStatus.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
class DriftBackupState {
|
||||
@@ -239,7 +267,6 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
|
||||
state = state.copyWith(uploadItems: {...state.uploadItems, taskId: currentItem.copyWith(isFailed: true)});
|
||||
_logger.fine("Upload failed for taskId: $taskId, exception: ${update.exception}");
|
||||
break;
|
||||
|
||||
case TaskStatus.canceled:
|
||||
|
||||
@@ -3,10 +3,7 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -39,7 +36,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
late ActionService _service;
|
||||
late UploadService _uploadService;
|
||||
late DownloadService _downloadService;
|
||||
late AssetService _assetService;
|
||||
|
||||
ActionNotifier() : super();
|
||||
|
||||
@@ -47,7 +43,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
void build() {
|
||||
_uploadService = ref.watch(uploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_assetService = ref.watch(assetServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||
@@ -340,14 +335,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
final assets = _getOwnedRemoteAssetsForSource(source);
|
||||
try {
|
||||
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
|
||||
if (source == ActionSource.viewer) {
|
||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||
if (updatedParent != null) {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to unstack assets', error, stack);
|
||||
|
||||
@@ -28,8 +28,6 @@ class MultiSelectState {
|
||||
bool get hasRemote =>
|
||||
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
|
||||
|
||||
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
||||
|
||||
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||
|
||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/constants.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/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
@@ -20,9 +19,9 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final uploadServiceProvider = Provider((ref) {
|
||||
final service = UploadService(
|
||||
@@ -206,20 +205,10 @@ class UploadService {
|
||||
return _uploadRepository.start();
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
_handleLivePhoto(update);
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
try {
|
||||
final path = await update.task.filePath();
|
||||
await File(path).delete();
|
||||
} catch (e) {
|
||||
_logger.severe('Error deleting file path for iOS: $e');
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
|
||||
class ActionButtonContext {
|
||||
@@ -25,7 +24,6 @@ class ActionButtonContext {
|
||||
final bool isArchived;
|
||||
final bool isTrashEnabled;
|
||||
final bool isInLockedView;
|
||||
final bool isStacked;
|
||||
final RemoteAlbum? currentAlbum;
|
||||
final bool advancedTroubleshooting;
|
||||
final ActionSource source;
|
||||
@@ -35,7 +33,6 @@ class ActionButtonContext {
|
||||
required this.isOwner,
|
||||
required this.isArchived,
|
||||
required this.isTrashEnabled,
|
||||
required this.isStacked,
|
||||
required this.isInLockedView,
|
||||
required this.currentAlbum,
|
||||
required this.advancedTroubleshooting,
|
||||
@@ -58,7 +55,6 @@ enum ActionButtonType {
|
||||
deleteLocal,
|
||||
upload,
|
||||
removeFromAlbum,
|
||||
unstack,
|
||||
likeActivity;
|
||||
|
||||
bool shouldShow(ActionButtonContext context) {
|
||||
@@ -114,10 +110,6 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.unstack =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.isStacked,
|
||||
ActionButtonType.likeActivity =>
|
||||
!context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
@@ -146,13 +138,28 @@ enum ActionButtonType {
|
||||
source: context.source,
|
||||
),
|
||||
ActionButtonType.likeActivity => const LikeActivityActionButton(),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ActionButtonBuilder {
|
||||
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||
static const List<ActionButtonType> _actionTypes = [
|
||||
ActionButtonType.advancedInfo,
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.shareLink,
|
||||
ActionButtonType.likeActivity,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.download,
|
||||
ActionButtonType.trash,
|
||||
ActionButtonType.deletePermanent,
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.moveToLockFolder,
|
||||
ActionButtonType.removeFromLockFolder,
|
||||
ActionButtonType.deleteLocal,
|
||||
ActionButtonType.upload,
|
||||
ActionButtonType.removeFromAlbum,
|
||||
];
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
|
||||
@@ -129,24 +129,19 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
title: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3.0),
|
||||
child: SvgPicture.asset(
|
||||
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
const Tooltip(
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
showDuration: Duration(seconds: 4),
|
||||
message:
|
||||
"The old timeline is deprecated and will be removed in a future release. Kindly switch to the new timeline under Advanced Settings.",
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 3.0),
|
||||
child: Icon(Icons.error_rounded, fill: 1, color: Colors.amber, size: 20),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3.0),
|
||||
child: SvgPicture.asset(
|
||||
context.isDarkTheme
|
||||
? 'assets/immich-logo-inline-dark.svg'
|
||||
: 'assets/immich-logo-inline-light.svg',
|
||||
height: 40,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,7 +82,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -113,7 +112,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -129,7 +127,6 @@ void main() {
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -148,7 +145,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -165,7 +161,6 @@ void main() {
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -182,7 +177,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -201,7 +195,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -218,7 +211,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -235,7 +227,6 @@ void main() {
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -252,7 +243,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -269,7 +259,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -288,7 +277,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -305,7 +293,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -322,7 +309,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -341,7 +327,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -358,7 +343,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -375,7 +359,6 @@ void main() {
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -394,7 +377,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -411,7 +393,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -430,7 +411,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -447,7 +427,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -466,7 +445,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -485,7 +463,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -504,7 +481,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -521,7 +497,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -537,7 +512,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -556,7 +530,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -575,7 +548,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -591,7 +563,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -610,7 +581,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -627,7 +597,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -644,7 +613,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -660,7 +628,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -678,7 +645,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: true,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -694,7 +660,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -703,59 +668,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('unstack button', () {
|
||||
test('should show when owner, not locked, has remote, and is stacked', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: true,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unstack.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when not stacked', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when not owner', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: false,
|
||||
isArchived: true,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unstack.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonType.buildButton', () {
|
||||
late BaseAsset asset;
|
||||
late ActionButtonContext context;
|
||||
@@ -770,7 +682,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
});
|
||||
@@ -787,22 +698,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
final widget = buttonType.buildButton(contextWithAlbum);
|
||||
expect(widget, isA<Widget>());
|
||||
} else if (buttonType == ActionButtonType.unstack) {
|
||||
final album = createRemoteAlbum();
|
||||
final contextWithAlbum = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: true,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
final widget = buttonType.buildButton(contextWithAlbum);
|
||||
@@ -826,7 +721,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -847,7 +741,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -866,7 +759,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -886,7 +778,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -900,7 +791,6 @@ void main() {
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
|
||||
|
||||
ARG TUSD_RELEASE=v2.8.0
|
||||
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
|
||||
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
|
||||
|
||||
# TODO: move this to base image
|
||||
RUN apt-get update && apt-get install -y curl && \
|
||||
if [ $(arch) = "x86_64" ]; then \
|
||||
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
|
||||
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
|
||||
else \
|
||||
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
|
||||
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
|
||||
fi && \
|
||||
dpkg -i /tmp/tusd.deb && \
|
||||
rm -f /tmp/tusd.deb
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
@@ -40,6 +57,7 @@ ENV NODE_ENV=production \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
COPY --from=builder /usr/bin/tusd /usr/bin/tusd
|
||||
COPY --from=server /output/server-pruned ./server
|
||||
COPY --from=web /usr/src/app/web/build /build/www
|
||||
COPY --from=cli /output/cli-pruned ./cli
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
|
||||
|
||||
ARG TUSD_RELEASE=v2.8.0
|
||||
ARG TUSD_AMD64_SHA256=33aaa15e14c7b025772d28605c6e0733ec2e8c1d6b646fa6b83af0049022a9ce
|
||||
ARG TUSD_ARM64_SHA256=07f78ee2fd552296c40f3a7a5a06325a06b53f1dd1ba996a60ecc52696906481
|
||||
|
||||
# TODO: move this to base image
|
||||
RUN if [ $(arch) = "x86_64" ]; then \
|
||||
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_amd64.deb" && \
|
||||
echo "${TUSD_AMD64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
|
||||
else \
|
||||
curl -fsSL -o /tmp/tusd.deb "https://github.com/tus/tusd/releases/download/${TUSD_RELEASE}/tusd_snapshot_arm64.deb" && \
|
||||
echo "${TUSD_ARM64_SHA256} /tmp/tusd.deb" | sha256sum -c; \
|
||||
fi && \
|
||||
dpkg -i /tmp/tusd.deb && \
|
||||
rm -f /tmp/tusd.deb
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
|
||||
@@ -28,7 +28,6 @@ import { getKyselyConfig } from 'src/utils/database';
|
||||
const common = [...repositories, ...services, GlobalExceptionFilter];
|
||||
|
||||
export const middleware = [
|
||||
FileUploadInterceptor,
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||
@@ -85,7 +84,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
@Module({
|
||||
imports: [...imports, ScheduleModule.forRoot()],
|
||||
controllers: [...controllers],
|
||||
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
|
||||
providers: [...common, ...middleware, FileUploadInterceptor, { provide: IWorker, useValue: ImmichWorker.Api }],
|
||||
})
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
|
||||
export const UPLOAD_TUSD_SOCKET_PATH = '/tmp/immich-tusd.sock';
|
||||
export const UPLOAD_CHUNK_DIRECTORY = '/tmp/immich-chunked-uploads';
|
||||
export const UPLOAD_TUSD_CONNECT_TIMEOUT_MS = 1000;
|
||||
export const UPLOAD_TUSD_CONNECT_BACKOFF_MS = 100;
|
||||
export const UPLOAD_TUSD_CONNECT_RETRIES = 30;
|
||||
|
||||
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
|
||||
cube: 'cube',
|
||||
|
||||
82
server/src/controllers/asset-upload.controller.ts
Normal file
82
server/src/controllers/asset-upload.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { All, BadRequestException, Body, Controller, HttpException, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
TusdHookRequestDto,
|
||||
TusdHookRequestType,
|
||||
TusdHookResponseDto,
|
||||
TusdPreCreateEventDto,
|
||||
TusdPreFinishEventDto,
|
||||
} from 'src/dtos/upload.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
|
||||
@ApiTags('Upload')
|
||||
@Controller('upload')
|
||||
export class AssetUploadController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private uploadService: AssetUploadService,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
logger.setContext(AssetUploadController.name);
|
||||
}
|
||||
|
||||
// Proxies chunked upload requests to the tusd server.
|
||||
// Auth is handled in the pre-create and pre-finish hooks from tusd.
|
||||
@All('asset{/*all}')
|
||||
handleChunks(@Req() request: Request, @Res() response: Response): Promise<unknown> {
|
||||
return this.uploadService.proxyChunks(request, response);
|
||||
}
|
||||
|
||||
// This controller handles webhooks from the tusd server.
|
||||
// See https://tus.github.io/tusd/advanced-topics/hooks/ for more information.
|
||||
@Post('hook')
|
||||
async processHook(@Body() payload: TusdHookRequestDto): Promise<TusdHookResponseDto> {
|
||||
const request = payload.Event.HTTPRequest;
|
||||
const lowerCaseHeaders: Record<string, string> = {};
|
||||
for (const [key, [value]] of Object.entries(request.Header)) {
|
||||
lowerCaseHeaders[key.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await this.authService.authenticate({
|
||||
headers: lowerCaseHeaders,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, permission: Permission.AssetUpload, uri: request.URI },
|
||||
});
|
||||
|
||||
switch (payload.Type) {
|
||||
case TusdHookRequestType.PreCreate: {
|
||||
const dto = plainToInstance(TusdPreCreateEventDto, payload.Event);
|
||||
const errors = validateSync(dto, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException('Invalid payload');
|
||||
}
|
||||
return await this.uploadService.handlePreCreate(auth, dto, lowerCaseHeaders);
|
||||
}
|
||||
case TusdHookRequestType.PreFinish: {
|
||||
const dto = plainToInstance(TusdPreFinishEventDto, payload.Event);
|
||||
const errors = validateSync(dto, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException('Invalid payload');
|
||||
}
|
||||
return await this.uploadService.handlePreFinish(auth, dto);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled hook type: ${payload.Type}`);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof HttpException) {
|
||||
return { RejectUpload: true, HTTPResponse: { StatusCode: error.getStatus(), Body: error.message } };
|
||||
}
|
||||
this.logger.error('Error processing upload hook', error);
|
||||
return { RejectUpload: true, HTTPResponse: { StatusCode: 500 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
@@ -40,6 +41,7 @@ export const controllers = [
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetMediaController,
|
||||
AssetUploadController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DownloadController,
|
||||
|
||||
133
server/src/dtos/upload.dto.ts
Normal file
133
server/src/dtos/upload.dto.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { AssetMediaCreateDto } from 'src/dtos/asset-media.dto';
|
||||
|
||||
export enum TusdHookRequestType {
|
||||
PreCreate = 'pre-create',
|
||||
PreFinish = 'pre-finish',
|
||||
}
|
||||
|
||||
export enum TusdHookStorageType {
|
||||
FileStore = 'filestore',
|
||||
}
|
||||
|
||||
export class TusdStorageDto {
|
||||
@IsEnum(TusdHookStorageType)
|
||||
Type!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Path!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
InfoPath!: string;
|
||||
}
|
||||
|
||||
export class UploadAssetDataDto extends AssetMediaCreateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
declare filename: string;
|
||||
}
|
||||
|
||||
export class TusdMetaDataDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
declare AssetData: string; // base64-encoded JSON string of UploadAssetDataDto
|
||||
}
|
||||
|
||||
export class TusdPreCreateUploadDto {
|
||||
@IsInt()
|
||||
Size!: number;
|
||||
}
|
||||
|
||||
export class TusdPreFinishUploadDto {
|
||||
@IsUUID()
|
||||
ID!: string;
|
||||
|
||||
@IsInt()
|
||||
Size!: number;
|
||||
|
||||
@Type(() => TusdMetaDataDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
MetaData!: TusdMetaDataDto;
|
||||
|
||||
@Type(() => TusdStorageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
Storage!: TusdStorageDto;
|
||||
}
|
||||
|
||||
export class TusdHttpRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
Method!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
URI!: string;
|
||||
|
||||
@IsObject()
|
||||
Header!: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export class TusdPreCreateEventDto {
|
||||
@Type(() => TusdPreCreateUploadDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
Upload!: TusdPreCreateUploadDto;
|
||||
|
||||
@Type(() => TusdHttpRequestDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
HTTPRequest!: TusdHttpRequestDto;
|
||||
}
|
||||
|
||||
export class TusdPreFinishEventDto {
|
||||
@Type(() => TusdPreFinishUploadDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
Upload!: TusdPreFinishUploadDto;
|
||||
|
||||
@Type(() => TusdHttpRequestDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
HTTPRequest!: TusdHttpRequestDto;
|
||||
}
|
||||
|
||||
export class TusdHookRequestDto {
|
||||
@IsEnum(TusdHookRequestType)
|
||||
Type!: TusdHookRequestType;
|
||||
|
||||
@IsObject()
|
||||
Event!: TusdPreCreateEventDto | TusdPreFinishEventDto;
|
||||
}
|
||||
|
||||
export class TusdHttpResponseDto {
|
||||
StatusCode!: number;
|
||||
|
||||
Body?: string;
|
||||
|
||||
Header?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class TusdChangeFileInfoStorageDto {
|
||||
Path?: string;
|
||||
}
|
||||
|
||||
export class TusdChangeFileInfoDto {
|
||||
ID?: string;
|
||||
|
||||
MetaData?: TusdMetaDataDto;
|
||||
|
||||
Storage?: TusdChangeFileInfoStorageDto;
|
||||
}
|
||||
|
||||
export class TusdHookResponseDto {
|
||||
HTTPResponse?: TusdHttpResponseDto;
|
||||
|
||||
RejectUpload?: boolean;
|
||||
|
||||
ChangeFileInfo?: TusdChangeFileInfoDto;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export enum ImmichHeader {
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
Cid = 'x-immich-cid',
|
||||
AssetData = 'x-immich-asset-data',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
@@ -493,6 +494,7 @@ export enum BootstrapEventPriority {
|
||||
JobService = -190,
|
||||
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
|
||||
SystemConfig = 100,
|
||||
UploadService = 200,
|
||||
}
|
||||
|
||||
export enum QueueName {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
@@ -134,6 +134,16 @@ export class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
unlinkSync(file: string) {
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
|
||||
await fs.rm(folder, options);
|
||||
}
|
||||
@@ -156,10 +166,13 @@ export class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
mkdir(filepath: string): Promise<String | undefined> {
|
||||
return fs.mkdir(filepath, { recursive: true });
|
||||
}
|
||||
|
||||
mkdirSync(filepath: string): void {
|
||||
if (!existsSync(filepath)) {
|
||||
mkdirSync(filepath, { recursive: true });
|
||||
}
|
||||
// does not throw an error if the folder already exists
|
||||
mkdirSync(filepath, { recursive: true });
|
||||
}
|
||||
|
||||
existsSync(filepath: string) {
|
||||
|
||||
318
server/src/services/asset-upload.service.ts
Normal file
318
server/src/services/asset-upload.service.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { Request, Response } from 'express';
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { createConnection } from 'node:net';
|
||||
import { extname, join, parse, resolve } from 'node:path';
|
||||
import {
|
||||
UPLOAD_CHUNK_DIRECTORY,
|
||||
UPLOAD_TUSD_CONNECT_BACKOFF_MS,
|
||||
UPLOAD_TUSD_CONNECT_RETRIES,
|
||||
UPLOAD_TUSD_CONNECT_TIMEOUT_MS,
|
||||
UPLOAD_TUSD_SOCKET_PATH,
|
||||
} from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
TusdHookResponseDto,
|
||||
TusdPreCreateEventDto,
|
||||
TusdPreFinishEventDto,
|
||||
UploadAssetDataDto,
|
||||
} from 'src/dtos/upload.dto';
|
||||
import { AssetVisibility, ImmichHeader, ImmichWorker, JobName, StorageFolder } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadService extends BaseService {
|
||||
private tusdProcess?: ChildProcess;
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Api] })
|
||||
async onBootstrap() {
|
||||
await this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true });
|
||||
await this.storageRepository.mkdir(UPLOAD_CHUNK_DIRECTORY);
|
||||
const { host, port } = this.configRepository.getEnv();
|
||||
|
||||
const args = [
|
||||
'-unix-sock',
|
||||
UPLOAD_TUSD_SOCKET_PATH,
|
||||
'--base-path',
|
||||
'/api/upload/asset',
|
||||
'--upload-dir',
|
||||
UPLOAD_CHUNK_DIRECTORY,
|
||||
'--hooks-http',
|
||||
`http://${host ?? 'localhost'}:${port}:/api/upload/hook`,
|
||||
'--hooks-enabled-events',
|
||||
'pre-create,pre-finish',
|
||||
'--behind-proxy',
|
||||
'-enable-experimental-protocol',
|
||||
'-shutdown-timeout',
|
||||
'5s',
|
||||
'-hooks-http-forward-headers',
|
||||
Object.values(ImmichHeader).join(',') + ',authorization',
|
||||
'-disable-download',
|
||||
'-disable-termination',
|
||||
];
|
||||
|
||||
this.logger.log(`Starting tusd server with args: ${args.join(' ')}`);
|
||||
|
||||
this.tusdProcess = spawn('tusd', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.tusdProcess.stdout?.on('data', (data) => this.logger.verboseFn(() => `tusd: ${data.toString().trim()}`));
|
||||
|
||||
this.tusdProcess.stderr?.on('data', (data) => this.logger.warn(`tusd: ${data.toString().trim()}`));
|
||||
|
||||
this.tusdProcess.on('error', (error) => {
|
||||
this.logger.error(`tusd failed to start: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
this.tusdProcess.on('exit', async (code) => {
|
||||
this.tusdProcess = undefined;
|
||||
try {
|
||||
await Promise.all([
|
||||
this.storageRepository.unlink(UPLOAD_TUSD_SOCKET_PATH),
|
||||
this.storageRepository.unlinkDir(UPLOAD_CHUNK_DIRECTORY, { recursive: true, force: true }),
|
||||
]);
|
||||
} finally {
|
||||
if (code) {
|
||||
this.logger.error(`tusd exited unexpectedly with code ${code}`);
|
||||
// TODO: more graceful shutdown
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Waiting for tusd server...');
|
||||
await this.waitForTusd(UPLOAD_TUSD_SOCKET_PATH);
|
||||
this.logger.log('tusd server started successfully');
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppShutdown' })
|
||||
onShutdown() {
|
||||
this.tusdProcess?.kill('SIGTERM');
|
||||
this.tusdProcess = undefined;
|
||||
}
|
||||
|
||||
async proxyChunks(request: Request, response: Response): Promise<unknown> {
|
||||
if (!this.tusdProcess) {
|
||||
throw new InternalServerErrorException('tusd server is not running');
|
||||
}
|
||||
|
||||
delete request.headers.host;
|
||||
request.headers.connection = 'close'; // not sure why, but it doesn't respond for 60 seconds without this
|
||||
return new Promise((resolve, reject) => {
|
||||
const proxyReq = httpRequest(
|
||||
{
|
||||
socketPath: UPLOAD_TUSD_SOCKET_PATH,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
},
|
||||
(proxyRes) => {
|
||||
response.status(proxyRes.statusCode || 200);
|
||||
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
||||
if (value !== undefined) {
|
||||
response.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
proxyRes.pipe(response);
|
||||
proxyRes.on('end', resolve);
|
||||
proxyRes.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (error: any) => {
|
||||
this.logger.error(`Failed to proxy to tusd: ${error.message}`);
|
||||
reject(new InternalServerErrorException('Upload service unavailable'));
|
||||
});
|
||||
|
||||
if (request.readable) {
|
||||
request.pipe(proxyReq);
|
||||
} else {
|
||||
proxyReq.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForTusd(socketPath: string, maxRetries = UPLOAD_TUSD_CONNECT_RETRIES): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const ready = await new Promise<boolean>((resolve) => {
|
||||
const socket = createConnection(socketPath, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.setTimeout(UPLOAD_TUSD_CONNECT_TIMEOUT_MS);
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, UPLOAD_TUSD_CONNECT_BACKOFF_MS));
|
||||
}
|
||||
|
||||
throw new Error('tusd server failed to start within timeout');
|
||||
}
|
||||
|
||||
async handlePreCreate(
|
||||
auth: AuthDto,
|
||||
payload: TusdPreCreateEventDto,
|
||||
headers: Record<string, string>,
|
||||
): Promise<TusdHookResponseDto> {
|
||||
this.logger.debugFn(() => `PreCreate hook received: ${JSON.stringify(payload)}`);
|
||||
const checksum = headers[ImmichHeader.Checksum]?.[0];
|
||||
if (checksum) {
|
||||
const existingId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (existingId) {
|
||||
const body = JSON.stringify({ status: AssetMediaStatus.DUPLICATE, id: existingId });
|
||||
return {
|
||||
RejectUpload: true,
|
||||
HTTPResponse: { StatusCode: 200, Body: body },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.requireQuota(auth, payload.Upload.Size);
|
||||
const encodedAssetData = headers[ImmichHeader.AssetData];
|
||||
if (!encodedAssetData) {
|
||||
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
|
||||
}
|
||||
|
||||
const assetData = this.parseMetadata(encodedAssetData);
|
||||
this.logger.log('assetData: ' + JSON.stringify(assetData));
|
||||
|
||||
const assetId = this.cryptoRepository.randomUUID();
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
||||
const extension = extname(assetId);
|
||||
const path = join(folder, `${assetId}${extension}`);
|
||||
return {
|
||||
ChangeFileInfo: {
|
||||
ID: assetId,
|
||||
Storage: { Path: path },
|
||||
MetaData: {
|
||||
AssetData: encodedAssetData,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async handlePreFinish(auth: AuthDto, dto: TusdPreFinishEventDto): Promise<TusdHookResponseDto> {
|
||||
this.logger.debugFn(() => `PreFinish hook received: ${JSON.stringify(dto)}`);
|
||||
const {
|
||||
Upload: {
|
||||
MetaData: { AssetData: encodedAssetData },
|
||||
Storage: { Path: path },
|
||||
Size: size,
|
||||
ID: assetId,
|
||||
},
|
||||
} = dto;
|
||||
|
||||
const parsedPath = parse(resolve(path));
|
||||
if (!parsedPath.dir.startsWith(StorageCore.getFolderLocation(StorageFolder.Upload, auth.user.id))) {
|
||||
throw new BadRequestException('Path is not in user folder');
|
||||
}
|
||||
const metadata = this.parseMetadata(encodedAssetData);
|
||||
|
||||
try {
|
||||
this.requireQuota(auth, size);
|
||||
const checksum = await this.cryptoRepository.hashFile(path);
|
||||
await this.storageRepository.utimes(path, new Date(), new Date(metadata.fileModifiedAt));
|
||||
try {
|
||||
await this.assetRepository.create({
|
||||
id: assetId,
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
checksum,
|
||||
originalPath: path,
|
||||
deviceAssetId: metadata.deviceAssetId,
|
||||
deviceId: metadata.deviceId,
|
||||
fileCreatedAt: metadata.fileCreatedAt,
|
||||
fileModifiedAt: metadata.fileModifiedAt,
|
||||
localDateTime: metadata.fileCreatedAt,
|
||||
type: mimeTypes.assetType(path),
|
||||
isFavorite: metadata.isFavorite,
|
||||
duration: metadata.duration || null,
|
||||
visibility: metadata.visibility || AssetVisibility.Timeline,
|
||||
originalFileName: metadata.filename || parsedPath.base,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
void this.tryUnlink(path);
|
||||
const body = JSON.stringify({ id: duplicateId, status: AssetMediaStatus.DUPLICATE });
|
||||
return { HTTPResponse: { StatusCode: 200, Body: body } };
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
void this.tryUnlink(path);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, size);
|
||||
await this.assetRepository.upsertExif({ assetId: assetId, fileSizeInByte: size });
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetExtractMetadata,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
});
|
||||
|
||||
this.logger.log(`Asset created from chunked upload: ${assetId}`);
|
||||
const body = JSON.stringify({ id: assetId, status: AssetMediaStatus.CREATED });
|
||||
return { HTTPResponse: { StatusCode: 201, Body: body } };
|
||||
}
|
||||
|
||||
private async tryUnlink(path: string): Promise<void> {
|
||||
try {
|
||||
await this.storageRepository.unlink(path);
|
||||
} catch {
|
||||
this.logger.warn(`Failed to remove file at ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
private parseMetadata(encodedAssetData: string): UploadAssetDataDto {
|
||||
let assetData: any;
|
||||
try {
|
||||
assetData = JSON.parse(Buffer.from(encodedAssetData, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
|
||||
}
|
||||
const dto = plainToInstance(UploadAssetDataDto, assetData);
|
||||
const assetDataErrors = validateSync(dto, { whitelist: true });
|
||||
if (assetDataErrors.length > 0 || !mimeTypes.isAsset(assetData.filename)) {
|
||||
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header`);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AlbumService } from 'src/services/album.service';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
@@ -47,6 +48,7 @@ export const services = [
|
||||
AlbumService,
|
||||
ApiService,
|
||||
AssetMediaService,
|
||||
AssetUploadService,
|
||||
AssetService,
|
||||
AuditService,
|
||||
AuthService,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiPlay } from '@mdi/js';
|
||||
import { clamp } from 'lodash-es';
|
||||
@@ -11,31 +11,18 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
timelineBottomOffset?: number;
|
||||
/** Total height of the scrubber component */
|
||||
height?: number;
|
||||
/** Timeline manager instance that controls the timeline state */
|
||||
timelineManager: TimelineManager;
|
||||
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
|
||||
timelineScrollPercent?: number;
|
||||
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
|
||||
viewportTopMonthScrollPercent?: number;
|
||||
/** The year/month of the timeline month at the top of the viewport */
|
||||
viewportTopMonth?: TimelineYearMonth;
|
||||
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
|
||||
isInLeadOutSection?: boolean;
|
||||
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
|
||||
scrubOverallPercent?: number;
|
||||
scrubberMonthPercent?: number;
|
||||
scrubberMonth?: { year: number; month: number };
|
||||
leadout?: boolean;
|
||||
scrubberWidth?: number;
|
||||
/** Callback fired when user interacts with the scrubber to navigate */
|
||||
onScrub?: ScrubberListener;
|
||||
/** Callback fired when keyboard events occur on the scrubber */
|
||||
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||
/** Callback fired when scrubbing starts */
|
||||
startScrub?: ScrubberListener;
|
||||
/** Callback fired when scrubbing stops */
|
||||
stopScrub?: ScrubberListener;
|
||||
}
|
||||
|
||||
@@ -44,10 +31,10 @@
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
timelineManager,
|
||||
timelineScrollPercent = 0,
|
||||
viewportTopMonthScrollPercent = 0,
|
||||
viewportTopMonth = undefined,
|
||||
isInLeadOutSection = false,
|
||||
scrubOverallPercent = 0,
|
||||
scrubberMonthPercent = 0,
|
||||
scrubberMonth = undefined,
|
||||
leadout = false,
|
||||
onScrub = undefined,
|
||||
onScrubKeyDown = undefined,
|
||||
startScrub = undefined,
|
||||
@@ -113,7 +100,7 @@
|
||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||
}
|
||||
return offset;
|
||||
} else if (isInLeadOutSection) {
|
||||
} else if (leadout) {
|
||||
let offset = relativeTopOffset;
|
||||
for (const segment of segments) {
|
||||
offset += segment.height;
|
||||
@@ -124,9 +111,7 @@
|
||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||
}
|
||||
};
|
||||
let scrollY = $derived(
|
||||
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||
);
|
||||
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
|
||||
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||
@@ -310,24 +295,12 @@
|
||||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void onScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,11 +308,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
void onScrub?.({
|
||||
scrubberMonth: segmentDate!,
|
||||
overallScrollPercent: scrollPercent,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
});
|
||||
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||
};
|
||||
/* eslint-disable tscompat/tscompat */
|
||||
const getTouch = (event: TouchEvent) => {
|
||||
@@ -443,11 +412,7 @@
|
||||
}
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
void onScrub?.({
|
||||
scrubberMonth: { year: next.year, month: next.month },
|
||||
overallScrollPercent: -1,
|
||||
scrubberMonthScrollPercent: 0,
|
||||
});
|
||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -457,11 +422,7 @@
|
||||
const next = segments[idx + 1];
|
||||
if (next) {
|
||||
event.preventDefault();
|
||||
void onScrub?.({
|
||||
scrubberMonth: { year: next.year, month: next.month },
|
||||
overallScrollPercent: -1,
|
||||
scrubberMonthScrollPercent: 0,
|
||||
});
|
||||
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import type { AbsoluteResult, RelativeResult } from '$lib/components/shared-components/change-date.svelte';
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
@@ -15,17 +21,25 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import DeleteAssetDialog from '../photos-page/delete-asset-dialog.svelte';
|
||||
import TimelineDateGroup from './TimelineDateGroup.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -95,21 +109,16 @@
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
// The timeline month intersecting the top position of the viewport
|
||||
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let isShowSelectDate = $state(false);
|
||||
let scrubberMonthPercent = $state(0);
|
||||
let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
|
||||
let scrubOverallPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
|
||||
// 60 is the bottom spacer element at 60px
|
||||
let bottomSectionHeight = 60;
|
||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||
let isInLeadOutSection = $state(false);
|
||||
let leadout = $state(false);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
|
||||
@@ -292,19 +301,20 @@
|
||||
scrollTop(scrollToTop);
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
|
||||
const onScrub: ScrubberListener = (scrubberData) => {
|
||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||
|
||||
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const onScrub: ScrubberListener = (
|
||||
scrubMonth: { year: number; month: number },
|
||||
overallScrollPercent: number,
|
||||
scrubberMonthScrollPercent: number,
|
||||
) => {
|
||||
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * overallScrollPercent;
|
||||
scrollTop(offset);
|
||||
} else {
|
||||
const monthGroup = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
|
||||
);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
@@ -315,7 +325,7 @@
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
leadout = false;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -324,19 +334,19 @@
|
||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
} else {
|
||||
let top = element.scrollTop;
|
||||
if (top < timelineManager.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -361,15 +371,15 @@
|
||||
let next = top - monthGroupHeight * maxScrollPercent;
|
||||
// instead of checking for < 0, add a little wiggle room for subpixel resolution
|
||||
if (next < -1 && monthGroup) {
|
||||
viewportTopMonth = monthGroup;
|
||||
scrubberMonth = monthGroup;
|
||||
|
||||
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
|
||||
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
|
||||
|
||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||
scrubberMonth = timelineManager.months[i + 1].yearMonth;
|
||||
scrubberMonthPercent = 0;
|
||||
}
|
||||
|
||||
found = true;
|
||||
@@ -378,14 +388,61 @@
|
||||
top = next;
|
||||
}
|
||||
if (!found) {
|
||||
isInLeadOutSection = true;
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
timelineScrollPercent = 1;
|
||||
leadout = true;
|
||||
scrubberMonth = undefined;
|
||||
scrubberMonthPercent = 0;
|
||||
scrubOverallPercent = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||
|
||||
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||
};
|
||||
|
||||
const onForceDelete = () => {
|
||||
if ($showDeleteModal) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(true));
|
||||
};
|
||||
|
||||
const onStackAssets = async () => {
|
||||
const result = await stackAssets(assetInteraction.selectedAssets);
|
||||
|
||||
updateStackedAssetInTimeline(timelineManager, result);
|
||||
|
||||
onEscape();
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.visibility = visibility;
|
||||
return { remove: false };
|
||||
});
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||
if (!timelineManager.albumAssets.has(asset.id)) {
|
||||
assetInteraction.selectAsset(asset);
|
||||
@@ -516,7 +573,15 @@
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
|
||||
const deselectAllAssets = () => {
|
||||
cancelMultiselect(assetInteraction);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
@@ -524,11 +589,16 @@
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||
if (asset) {
|
||||
void selectAssetCandidates(asset);
|
||||
@@ -654,6 +724,72 @@
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
const onSelectStart = (e: Event) => {
|
||||
if (assetInteraction.selectionActive && shiftKeyIsDown) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
const handleOpenShortcutModal = async () => {
|
||||
if (isShortcutModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShortcutModalOpen = true;
|
||||
await modalManager.show(ShortcutsModal, {});
|
||||
isShortcutModalOpen = false;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isEmpty) {
|
||||
assetInteraction.clearMultiselect();
|
||||
}
|
||||
});
|
||||
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutOptions[] = [
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.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: () => (isShowSelectDate = true) },
|
||||
];
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
})(),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!lastAssetMouseEvent) {
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
@@ -680,17 +816,37 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
|
||||
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
|
||||
|
||||
<TimelineKeyboardActions
|
||||
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
bind:isShowDeleteConfirmation
|
||||
{onEscape}
|
||||
/>
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={idsSelectedAssets.length}
|
||||
onCancel={() => (isShowDeleteConfirmation = false)}
|
||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowSelectDate}
|
||||
<ChangeDate
|
||||
title="Navigate to Time"
|
||||
initialDate={DateTime.now()}
|
||||
timezoneInput={false}
|
||||
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
|
||||
isShowSelectDate = false;
|
||||
if (dateString.mode == 'absolute') {
|
||||
const asset = await timelineManager.getClosestAssetToDate(
|
||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
||||
);
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => (isShowSelectDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
@@ -698,10 +854,10 @@
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{isInLeadOutSection}
|
||||
{timelineScrollPercent}
|
||||
{viewportTopMonthScrollPercent}
|
||||
{viewportTopMonth}
|
||||
{leadout}
|
||||
{scrubOverallPercent}
|
||||
{scrubberMonthPercent}
|
||||
{scrubberMonth}
|
||||
{onScrub}
|
||||
bind:scrubberWidth
|
||||
onScrubKeyDown={(evt) => {
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import ChangeDate, {
|
||||
type AbsoluteResult,
|
||||
type RelativeResult,
|
||||
} from '$lib/components/shared-components/change-date.svelte';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
isShowDeleteConfirmation: boolean;
|
||||
onEscape?: () => void;
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
timelineManager = $bindable(),
|
||||
assetInteraction,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onEscape,
|
||||
scrollToAsset,
|
||||
}: Props = $props();
|
||||
|
||||
let isShowSelectDate = $state(false);
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||
|
||||
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||
};
|
||||
|
||||
const onForceDelete = () => {
|
||||
if ($showDeleteModal) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(true));
|
||||
};
|
||||
|
||||
const onStackAssets = async () => {
|
||||
const result = await stackAssets(assetInteraction.selectedAssets);
|
||||
|
||||
updateStackedAssetInTimeline(timelineManager, result);
|
||||
|
||||
onEscape?.();
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
|
||||
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||
asset.visibility = visibility;
|
||||
return { remove: false };
|
||||
});
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
|
||||
const deselectAllAssets = () => {
|
||||
cancelMultiselect(assetInteraction);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (searchStore.isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectStart = (e: Event) => {
|
||||
if (assetInteraction.selectionActive && shiftKeyIsDown) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
const handleOpenShortcutModal = async () => {
|
||||
if (isShortcutModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShortcutModalOpen = true;
|
||||
await modalManager.show(ShortcutsModal, {});
|
||||
isShortcutModalOpen = false;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isEmpty) {
|
||||
assetInteraction.clearMultiselect();
|
||||
}
|
||||
});
|
||||
|
||||
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
|
||||
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutOptions[] = [
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.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: () => (isShowSelectDate = true) },
|
||||
];
|
||||
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: onForceDelete },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
})(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={idsSelectedAssets.length}
|
||||
onCancel={() => (isShowDeleteConfirmation = false)}
|
||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowSelectDate}
|
||||
<ChangeDate
|
||||
withDuration={false}
|
||||
title="Navigate to Time"
|
||||
initialDate={DateTime.now()}
|
||||
timezoneInput={false}
|
||||
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
|
||||
isShowSelectDate = false;
|
||||
if (dateString.mode == 'absolute') {
|
||||
const asset = await timelineManager.getClosestAssetToDate(
|
||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
||||
);
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => (isShowSelectDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -23,11 +23,11 @@ export type TimelineDateTime = TimelineDate & {
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export type ScrubberListener = (scrubberData: {
|
||||
scrubberMonth: { year: number; month: number };
|
||||
overallScrollPercent: number;
|
||||
scrubberMonthScrollPercent: number;
|
||||
}) => void | Promise<void>;
|
||||
export type ScrubberListener = (
|
||||
scrubberMonth: { year: number; month: number },
|
||||
overallScrollPercent: number,
|
||||
scrubberMonthScrollPercent: number,
|
||||
) => void | Promise<void>;
|
||||
|
||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||
|
||||
Reference in New Issue
Block a user