mirror of
https://github.com/immich-app/immich.git
synced 2026-05-14 11:32:15 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ccc05feeb |
@@ -131,7 +131,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||
|
||||
- name: Load parameters
|
||||
id: parameters
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||
|
||||
- name: Destroy Docs Subdomain
|
||||
env:
|
||||
|
||||
@@ -502,25 +502,14 @@ jobs:
|
||||
- name: Run e2e tests (web)
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --project=chromium
|
||||
run: npx playwright test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive web results
|
||||
- name: Archive test results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --project=ui
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Archive ui results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
|
||||
testMatch: /.*\.e2e-spec\.ts/,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'ui',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testMatch: /.*\.ui-spec\.ts/,
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
// {
|
||||
// name: 'parallel tests',
|
||||
// use: { ...devices['Desktop Chrome'] },
|
||||
// testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||
// fullyParallel: true,
|
||||
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
||||
+8
-24
@@ -104,8 +104,6 @@
|
||||
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
|
||||
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
||||
"image_preview_title": "Preview Settings",
|
||||
"image_progressive": "Progressive",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
|
||||
"image_quality": "Quality",
|
||||
"image_resolution": "Resolution",
|
||||
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||
@@ -514,7 +512,6 @@
|
||||
"all": "All",
|
||||
"all_albums": "All albums",
|
||||
"all_people": "All people",
|
||||
"all_photos": "All photos",
|
||||
"all_videos": "All videos",
|
||||
"allow_dark_mode": "Allow dark mode",
|
||||
"allow_edits": "Allow edits",
|
||||
@@ -522,9 +519,6 @@
|
||||
"allow_public_user_to_upload": "Allow public user to upload",
|
||||
"allowed": "Allowed",
|
||||
"alt_text_qr_code": "QR code image",
|
||||
"always_keep": "Always keep",
|
||||
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
|
||||
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
|
||||
"anti_clockwise": "Anti-clockwise",
|
||||
"api_key": "API Key",
|
||||
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
|
||||
@@ -757,13 +751,13 @@
|
||||
"cleanup_confirm_prompt_title": "Remove from this device?",
|
||||
"cleanup_deleted_assets": "Moved {count} assets to device trash",
|
||||
"cleanup_deleting": "Moving to trash...",
|
||||
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
|
||||
"cleanup_found_assets": "Found {count} backed up assets",
|
||||
"cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
|
||||
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
|
||||
"cleanup_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server",
|
||||
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
|
||||
"cleanup_preview_title": "Assets to remove ({count})",
|
||||
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
|
||||
"cleanup_step4_summary": "{count} assets (created before {date}) to remove from your local device. Photos will remain accessible from the Immich app.",
|
||||
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
|
||||
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
|
||||
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
@@ -861,7 +855,7 @@
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_date_description": "Remove photos and videos older than",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
"cutoff_year": "{count, plural, one {year} other {years}}",
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
@@ -1013,7 +1007,6 @@
|
||||
"error_change_sort_album": "Failed to change album sort order",
|
||||
"error_delete_face": "Error deleting face from asset",
|
||||
"error_getting_places": "Error getting places",
|
||||
"error_loading_albums": "Error loading albums",
|
||||
"error_loading_image": "Error loading image",
|
||||
"error_loading_partners": "Error loading partners: {error}",
|
||||
"error_retrieving_asset_information": "Error retrieving asset information",
|
||||
@@ -1196,6 +1189,7 @@
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
"filter_description": "Conditions to filter the target assets",
|
||||
"filter_options": "Filter options",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filters": "Filters",
|
||||
@@ -1209,7 +1203,7 @@
|
||||
"forgot_pin_code_question": "Forgot your PIN?",
|
||||
"forward": "Forward",
|
||||
"free_up_space": "Free Up Space",
|
||||
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
|
||||
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
|
||||
"free_up_space_settings_subtitle": "Free up device storage",
|
||||
"full_path": "Full path: {path}",
|
||||
"gcast_enabled": "Google Cast",
|
||||
@@ -1326,15 +1320,10 @@
|
||||
"json_editor": "JSON editor",
|
||||
"json_error": "JSON error",
|
||||
"keep": "Keep",
|
||||
"keep_albums": "Keep albums",
|
||||
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
|
||||
"keep_all": "Keep All",
|
||||
"keep_description": "Choose what stays on your device when freeing up space.",
|
||||
"keep_favorites": "Keep favorites",
|
||||
"keep_on_device": "Keep on device",
|
||||
"keep_on_device_hint": "Select items to keep on this device",
|
||||
"keep_favorites_description": "Favorite assets will not be deleted from your device",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
"keeping": "Keeping: {items}",
|
||||
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"language": "Language",
|
||||
@@ -1564,7 +1553,6 @@
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_found": "No albums found",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
@@ -1594,7 +1582,6 @@
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"none": "None",
|
||||
"not_allowed": "Not allowed",
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
@@ -1924,7 +1911,6 @@
|
||||
"search_filter_media_type_title": "Select media type",
|
||||
"search_filter_ocr": "Search by OCR",
|
||||
"search_filter_people_title": "Select people",
|
||||
"search_filter_star_rating": "Star Rating",
|
||||
"search_for": "Search for",
|
||||
"search_for_existing_person": "Search for existing person",
|
||||
"search_no_more_result": "No more results",
|
||||
@@ -2129,8 +2115,6 @@
|
||||
"skip_to_folders": "Skip to folders",
|
||||
"skip_to_tags": "Skip to tags",
|
||||
"slideshow": "Slideshow",
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
|
||||
@@ -8,6 +8,6 @@ enum SortUserBy { id }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
enum CleanupStep { selectDate, filterOptions, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
enum AssetFilterType { all, photosOnly, videosOnly }
|
||||
|
||||
@@ -30,8 +30,3 @@ class MultiSelectToggleEvent extends Event {
|
||||
final bool isEnabled;
|
||||
const MultiSelectToggleEvent(this.isEnabled);
|
||||
}
|
||||
|
||||
// Map Events
|
||||
class MapMarkerReloadEvent extends Event {
|
||||
const MapMarkerReloadEvent();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ class ExifInfo {
|
||||
final String? orientation;
|
||||
final String? timeZone;
|
||||
final DateTime? dateTimeOriginal;
|
||||
final int? rating;
|
||||
|
||||
// GPS
|
||||
final double? latitude;
|
||||
@@ -47,7 +46,6 @@ class ExifInfo {
|
||||
this.orientation,
|
||||
this.timeZone,
|
||||
this.dateTimeOriginal,
|
||||
this.rating,
|
||||
this.isFlipped = false,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -73,7 +71,6 @@ class ExifInfo {
|
||||
other.orientation == orientation &&
|
||||
other.timeZone == timeZone &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
other.rating == rating &&
|
||||
other.latitude == latitude &&
|
||||
other.longitude == longitude &&
|
||||
other.city == city &&
|
||||
@@ -97,7 +94,6 @@ class ExifInfo {
|
||||
isFlipped.hashCode ^
|
||||
timeZone.hashCode ^
|
||||
dateTimeOriginal.hashCode ^
|
||||
rating.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode ^
|
||||
city.hashCode ^
|
||||
@@ -122,7 +118,6 @@ orientation: ${orientation ?? 'NA'},
|
||||
isFlipped: $isFlipped,
|
||||
timeZone: ${timeZone ?? 'NA'},
|
||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||
rating: ${rating ?? 'NA'},
|
||||
latitude: ${latitude ?? 'NA'},
|
||||
longitude: ${longitude ?? 'NA'},
|
||||
city: ${city ?? 'NA'},
|
||||
@@ -145,7 +140,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
String? orientation,
|
||||
String? timeZone,
|
||||
DateTime? dateTimeOriginal,
|
||||
int? rating,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
@@ -167,7 +161,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
orientation: orientation ?? this.orientation,
|
||||
timeZone: timeZone ?? this.timeZone,
|
||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||
rating: rating ?? this.rating,
|
||||
isFlipped: isFlipped ?? this.isFlipped,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -82,14 +82,7 @@ enum StoreKey<T> {
|
||||
useWifiForUploadPhotos<bool>._(1005),
|
||||
needBetaMigration<bool>._(1006),
|
||||
// TODO: Remove this after patching open-api
|
||||
shouldResetSync<bool>._(1007),
|
||||
|
||||
// Free up space
|
||||
cleanupKeepFavorites<bool>._(1008),
|
||||
cleanupKeepMediaType<int>._(1009),
|
||||
cleanupKeepAlbumIds<String>._(1010),
|
||||
cleanupCutoffDaysAgo<int>._(1011),
|
||||
cleanupDefaultsInitialized<bool>._(1012);
|
||||
shouldResetSync<bool>._(1007);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/map.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||
@@ -12,8 +11,7 @@ class MapFactory {
|
||||
|
||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||
|
||||
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
|
||||
MapService(_mapRepository.remote(ownerIds, options));
|
||||
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
|
||||
}
|
||||
|
||||
class MapService {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -81,8 +82,8 @@ class TimelineFactory {
|
||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||
|
||||
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
|
||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||
TimelineService map(String userId, LatLngBounds bounds) =>
|
||||
TimelineService(_timelineRepository.map(userId, bounds, groupBy));
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
|
||||
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/utils/isolate.dart';
|
||||
@@ -23,13 +22,8 @@ class BackgroundSyncManager {
|
||||
final SyncCallback? onHashingComplete;
|
||||
final SyncErrorCallback? onHashingError;
|
||||
|
||||
final SyncCallback? onCloudIdSyncStart;
|
||||
final SyncCallback? onCloudIdSyncComplete;
|
||||
final SyncErrorCallback? onCloudIdSyncError;
|
||||
|
||||
Cancelable<bool?>? _syncTask;
|
||||
Cancelable<void>? _syncWebsocketTask;
|
||||
Cancelable<void>? _cloudIdSyncTask;
|
||||
Cancelable<void>? _deviceAlbumSyncTask;
|
||||
Cancelable<void>? _linkedAlbumSyncTask;
|
||||
Cancelable<void>? _hashTask;
|
||||
@@ -44,9 +38,6 @@ class BackgroundSyncManager {
|
||||
this.onHashingStart,
|
||||
this.onHashingComplete,
|
||||
this.onHashingError,
|
||||
this.onCloudIdSyncStart,
|
||||
this.onCloudIdSyncComplete,
|
||||
this.onCloudIdSyncError,
|
||||
});
|
||||
|
||||
Future<void> cancel() async {
|
||||
@@ -64,12 +55,6 @@ class BackgroundSyncManager {
|
||||
_syncWebsocketTask?.cancel();
|
||||
_syncWebsocketTask = null;
|
||||
|
||||
if (_cloudIdSyncTask != null) {
|
||||
futures.add(_cloudIdSyncTask!.future);
|
||||
}
|
||||
_cloudIdSyncTask?.cancel();
|
||||
_cloudIdSyncTask = null;
|
||||
|
||||
if (_linkedAlbumSyncTask != null) {
|
||||
futures.add(_linkedAlbumSyncTask!.future);
|
||||
}
|
||||
@@ -216,25 +201,6 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncCloudIds() {
|
||||
if (_cloudIdSyncTask != null) {
|
||||
return _cloudIdSyncTask!.future;
|
||||
}
|
||||
|
||||
onCloudIdSyncStart?.call();
|
||||
|
||||
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
|
||||
return _cloudIdSyncTask!
|
||||
.whenComplete(() {
|
||||
onCloudIdSyncComplete?.call();
|
||||
_cloudIdSyncTask = null;
|
||||
})
|
||||
.catchError((error) {
|
||||
onCloudIdSyncError?.call(error.toString());
|
||||
_cloudIdSyncTask = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||
|
||||
@@ -10,14 +10,13 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
Future<void> syncCloudIds(Ref ref) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
@@ -35,14 +34,6 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
}
|
||||
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
|
||||
|
||||
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
|
||||
try {
|
||||
await ref.read(syncStreamServiceProvider).sync();
|
||||
} catch (e, s) {
|
||||
logger.fine('Failed to complete remote sync before cloudId migration.', e, s);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
|
||||
final currentUser = ref.read(currentUserProvider);
|
||||
if (currentUser == null) {
|
||||
|
||||
@@ -151,7 +151,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
domain.ExifInfo toDto() => domain.ExifInfo(
|
||||
fileSize: fileSize,
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
rating: rating,
|
||||
timeZone: timeZone,
|
||||
make: make,
|
||||
model: model,
|
||||
|
||||
@@ -11,13 +11,6 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class RemovalCandidatesResult {
|
||||
final List<LocalAsset> assets;
|
||||
final int totalBytes;
|
||||
|
||||
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
|
||||
}
|
||||
|
||||
class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
@@ -137,12 +130,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<RemovalCandidatesResult> getRemovalCandidates(
|
||||
Future<List<LocalAsset>> getRemovalCandidates(
|
||||
String userId,
|
||||
DateTime cutoffDate, {
|
||||
AssetKeepType keepMediaType = AssetKeepType.none,
|
||||
AssetFilterType filterType = AssetFilterType.all,
|
||||
bool keepFavorites = true,
|
||||
Set<String> keepAlbumIds = const {},
|
||||
}) async {
|
||||
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
@@ -157,7 +149,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
|
||||
final query = _db.localAssetEntity.select().join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
|
||||
leftOuterJoin(_db.remoteExifEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId)),
|
||||
]);
|
||||
|
||||
Expression<bool> whereClause =
|
||||
@@ -168,19 +159,10 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
// Exclude assets that are in iOS shared albums
|
||||
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
|
||||
|
||||
if (keepAlbumIds.isNotEmpty) {
|
||||
final keepAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(_db.localAlbumAssetEntity.albumId.isIn(keepAlbumIds));
|
||||
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(keepAlbumAssets);
|
||||
}
|
||||
|
||||
if (keepMediaType == AssetKeepType.photosOnly) {
|
||||
// Keep photos = delete only videos
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
|
||||
} else if (keepMediaType == AssetKeepType.videosOnly) {
|
||||
// Keep videos = delete only photos
|
||||
if (filterType == AssetFilterType.photosOnly) {
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
|
||||
} else if (filterType == AssetFilterType.videosOnly) {
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
|
||||
}
|
||||
|
||||
if (keepFavorites) {
|
||||
@@ -190,13 +172,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
query.where(whereClause);
|
||||
|
||||
final rows = await query.get();
|
||||
final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
|
||||
final totalBytes = rows.fold<int>(0, (sum, row) {
|
||||
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
|
||||
return sum + (fileSize ?? 0);
|
||||
});
|
||||
|
||||
return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes);
|
||||
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class DriftMapRepository extends DriftDatabaseRepository {
|
||||
@@ -13,27 +12,9 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
|
||||
const DriftMapRepository(super._db) : _db = _db;
|
||||
|
||||
MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
|
||||
assetFilter: (row) {
|
||||
Expression<bool> condition =
|
||||
row.deletedAt.isNull() &
|
||||
row.ownerId.isIn(ownerIds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
|
||||
}
|
||||
|
||||
return condition;
|
||||
},
|
||||
MapQuery remote(String ownerId) => _mapQueryBuilder(
|
||||
assetFilter: (row) =>
|
||||
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
|
||||
);
|
||||
|
||||
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {
|
||||
|
||||
@@ -255,12 +255,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateRating(String assetId, int rating) async {
|
||||
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
|
||||
RemoteExifEntityCompanion(rating: Value(rating)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
return _db.managers.remoteAssetEntity.count();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
@@ -55,7 +54,6 @@ class SearchApiRepository extends ApiRepository {
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
|
||||
@@ -240,8 +240,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
rating: Value(exif.rating),
|
||||
projectionType: Value(exif.projectionType),
|
||||
lens: Value(exif.lensModel),
|
||||
width: Value(exif.exifImageWidth),
|
||||
height: Value(exif.exifImageHeight),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
|
||||
@@ -15,22 +15,6 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:stream_transform/stream_transform.dart';
|
||||
|
||||
class TimelineMapOptions {
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
this.onlyFavorites = false,
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
@@ -483,15 +467,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
|
||||
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count),
|
||||
origin: TimelineOrigin.map,
|
||||
);
|
||||
|
||||
Stream<List<Bucket>> _watchMapBucket(
|
||||
List<String> userId,
|
||||
TimelineMapOptions options, {
|
||||
String userId,
|
||||
LatLngBounds bounds, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
@@ -512,26 +496,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.isIn(userId) &
|
||||
_db.remoteExifEntity.inBounds(options.bounds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteExifEntity.inBounds(bounds) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
return query.map((row) {
|
||||
final timeline = row.read(dateExp)!.truncateDate(groupBy);
|
||||
final assetCount = row.read(assetCountExp)!;
|
||||
@@ -540,8 +512,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> _getMapBucketAssets(
|
||||
List<String> userId,
|
||||
TimelineMapOptions options, {
|
||||
String userId,
|
||||
LatLngBounds bounds, {
|
||||
required int offset,
|
||||
required int count,
|
||||
}) {
|
||||
@@ -554,26 +526,13 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.isIn(userId) &
|
||||
_db.remoteExifEntity.inBounds(options.bounds) &
|
||||
_db.remoteAssetEntity.visibility.isIn([
|
||||
AssetVisibility.timeline.index,
|
||||
if (options.includeArchived) AssetVisibility.archive.index,
|
||||
]) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteExifEntity.inBounds(bounds) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
if (options.onlyFavorites) {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
|
||||
@@ -126,41 +126,6 @@ class SearchDateFilter {
|
||||
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
|
||||
}
|
||||
|
||||
class SearchRatingFilter {
|
||||
int? rating;
|
||||
SearchRatingFilter({this.rating});
|
||||
|
||||
SearchRatingFilter copyWith({int? rating}) {
|
||||
return SearchRatingFilter(rating: rating ?? this.rating);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{'rating': rating};
|
||||
}
|
||||
|
||||
factory SearchRatingFilter.fromMap(Map<String, dynamic> map) {
|
||||
return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchRatingFilter.fromJson(String source) =>
|
||||
SearchRatingFilter.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'SearchRatingFilter(rating: $rating)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchRatingFilter other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.rating == rating;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => rating.hashCode;
|
||||
}
|
||||
|
||||
class SearchDisplayFilters {
|
||||
bool isNotInAlbum = false;
|
||||
bool isArchive = false;
|
||||
@@ -218,7 +183,6 @@ class SearchFilter {
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
SearchRatingFilter rating;
|
||||
SearchDisplayFilters display;
|
||||
|
||||
// Enum
|
||||
@@ -236,7 +200,6 @@ class SearchFilter {
|
||||
required this.camera,
|
||||
required this.date,
|
||||
required this.display,
|
||||
required this.rating,
|
||||
required this.mediaType,
|
||||
});
|
||||
|
||||
@@ -257,7 +220,6 @@ class SearchFilter {
|
||||
display.isNotInAlbum == false &&
|
||||
display.isArchive == false &&
|
||||
display.isFavorite == false &&
|
||||
rating.rating == null &&
|
||||
mediaType == AssetType.other;
|
||||
}
|
||||
|
||||
@@ -273,7 +235,6 @@ class SearchFilter {
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
SearchDisplayFilters? display,
|
||||
SearchRatingFilter? rating,
|
||||
AssetType? mediaType,
|
||||
}) {
|
||||
return SearchFilter(
|
||||
@@ -288,14 +249,13 @@ class SearchFilter {
|
||||
camera: camera ?? this.camera,
|
||||
date: date ?? this.date,
|
||||
display: display ?? this.display,
|
||||
rating: rating ?? this.rating,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -313,7 +273,6 @@ class SearchFilter {
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
other.display == display &&
|
||||
other.rating == rating &&
|
||||
other.mediaType == mediaType;
|
||||
}
|
||||
|
||||
@@ -330,7 +289,6 @@ class SearchFilter {
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
display.hashCode ^
|
||||
rating.hashCode ^
|
||||
mediaType.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
|
||||
body: section.widget,
|
||||
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
@@ -113,7 +113,6 @@ class PlaceTile extends StatelessWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,6 @@ class SearchPage extends HookConsumerWidget {
|
||||
date: prefilter?.date ?? SearchDateFilter(),
|
||||
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
mediaType: prefilter?.mediaType ?? AssetType.other,
|
||||
rating: prefilter?.rating ?? SearchRatingFilter(),
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -11,16 +10,6 @@ class DriftMapPage extends StatelessWidget {
|
||||
|
||||
const DriftMapPage({super.key, this.initialLocation});
|
||||
|
||||
void onSettingsPressed(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
elevation: 0.0,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => const DriftMapSettingsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -29,8 +18,8 @@ class DriftMapPage extends StatelessWidget {
|
||||
children: [
|
||||
DriftMap(initialLocation: initialLocation),
|
||||
Positioned(
|
||||
left: 20,
|
||||
top: 70,
|
||||
left: 16,
|
||||
top: 60,
|
||||
child: IconButton.filled(
|
||||
color: Colors.white,
|
||||
onPressed: () => context.pop(),
|
||||
@@ -43,21 +32,6 @@ class DriftMapPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: 70,
|
||||
child: IconButton.filled(
|
||||
color: Colors.white,
|
||||
onPressed: () => onSettingsPressed(context),
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(8),
|
||||
backgroundColor: Colors.indigo,
|
||||
shadowColor: Colors.black26,
|
||||
elevation: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
|
||||
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/feature_check.dart';
|
||||
@@ -31,7 +30,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
|
||||
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
|
||||
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftSearchPage extends HookConsumerWidget {
|
||||
@@ -50,7 +48,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
camera: preFilter?.camera ?? SearchCameraFilter(),
|
||||
date: preFilter?.date ?? SearchDateFilter(),
|
||||
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: preFilter?.rating ?? SearchRatingFilter(),
|
||||
mediaType: preFilter?.mediaType ?? AssetType.other,
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
assetId: preFilter?.assetId,
|
||||
@@ -65,15 +62,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final isSearching = useState(false);
|
||||
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
SnackBar searchInfoSnackBar(String message) {
|
||||
return SnackBar(
|
||||
content: Text(message, style: context.textTheme.labelLarge),
|
||||
@@ -377,35 +369,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// STAR RATING PICKER
|
||||
showStarRatingPicker() {
|
||||
handleOnSelected(SearchRatingFilter rating) {
|
||||
filter.value = filter.value.copyWith(rating: rating);
|
||||
|
||||
ratingCurrentFilterWidget.value = Text(
|
||||
'rating_count'.t(args: {'count': rating.rating!}),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
|
||||
ratingCurrentFilterWidget.value = null;
|
||||
search();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'rating'.t(context: context),
|
||||
onSearch: search,
|
||||
onClear: handleClear,
|
||||
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// DISPLAY OPTION
|
||||
showDisplayOptionPicker() {
|
||||
handleOnSelect(Map<DisplayOption, bool> value) {
|
||||
@@ -666,14 +629,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
),
|
||||
if (isRatingEnabled) ...[
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
),
|
||||
],
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -118,6 +118,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
bool assetReloadRequested = false;
|
||||
double? initialScale;
|
||||
double previousExtent = _kBottomSheetMinimumExtent;
|
||||
Offset dragDownPosition = Offset.zero;
|
||||
int totalAssets = 0;
|
||||
@@ -263,6 +264,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||
controller.position = Offset(0, -verticalOffset);
|
||||
// Apply the zoom effect when the bottom sheet is showing
|
||||
initialScale = controller.scale;
|
||||
controller.scale = (controller.scale ?? 1.0) + 0.01;
|
||||
}
|
||||
}
|
||||
@@ -314,7 +316,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||
scale: initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
@@ -364,9 +366,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final maxScaleDistance = ctx.height * 0.5;
|
||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||
double? updatedScale;
|
||||
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
|
||||
if (initialScale != null) {
|
||||
updatedScale = initialScale * (1.0 - scaleReduction);
|
||||
if (initialPhotoViewState.scale != null) {
|
||||
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||
}
|
||||
|
||||
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
@@ -480,6 +481,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
initialScale = viewController?.scale;
|
||||
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
@@ -501,7 +504,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: viewController?.initialScale);
|
||||
viewController?.updateMultiple(scale: initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
shouldPopOnDrag = false;
|
||||
|
||||
@@ -16,13 +16,11 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -206,9 +204,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
final isRatingEnabled = ref
|
||||
.watch(userMetadataPreferencesProvider)
|
||||
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
|
||||
|
||||
// Build file info tile based on asset type
|
||||
Widget buildFileInfoTile() {
|
||||
@@ -288,38 +283,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
// Rating bar
|
||||
if (isRatingEnabled) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
filledColor: context.themeData.colorScheme.primary,
|
||||
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
|
||||
itemSize: 40,
|
||||
onRatingUpdate: (rating) async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
|
||||
},
|
||||
onClearRating: () async {
|
||||
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Appears in (Albums)
|
||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||
// padding at the bottom to avoid cut-off
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class RatingBar extends StatefulWidget {
|
||||
final double initialRating;
|
||||
final int itemCount;
|
||||
final double itemSize;
|
||||
final Color filledColor;
|
||||
final Color unfilledColor;
|
||||
final ValueChanged<int>? onRatingUpdate;
|
||||
final VoidCallback? onClearRating;
|
||||
final Widget? itemBuilder;
|
||||
final double starPadding;
|
||||
|
||||
const RatingBar({
|
||||
super.key,
|
||||
this.initialRating = 0.0,
|
||||
this.itemCount = 5,
|
||||
this.itemSize = 40.0,
|
||||
this.filledColor = Colors.amber,
|
||||
this.unfilledColor = Colors.grey,
|
||||
this.onRatingUpdate,
|
||||
this.onClearRating,
|
||||
this.itemBuilder,
|
||||
this.starPadding = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RatingBar> createState() => _RatingBarState();
|
||||
}
|
||||
|
||||
class _RatingBarState extends State<RatingBar> {
|
||||
late double _currentRating;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentRating = widget.initialRating;
|
||||
}
|
||||
|
||||
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
|
||||
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
|
||||
double dx = localPosition.dx;
|
||||
|
||||
if (isRTL) dx = totalWidth - dx;
|
||||
|
||||
double newRating;
|
||||
|
||||
if (dx <= 0) {
|
||||
newRating = 0;
|
||||
} else if (dx >= totalWidth) {
|
||||
newRating = widget.itemCount.toDouble();
|
||||
} else {
|
||||
double starWithPadding = widget.itemSize + widget.starPadding;
|
||||
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
|
||||
newRating = tappedIndex + 1.0;
|
||||
|
||||
if (isTap && newRating == _currentRating && _currentRating != 0) {
|
||||
newRating = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentRating != newRating) {
|
||||
setState(() {
|
||||
_currentRating = newRating;
|
||||
});
|
||||
widget.onRatingUpdate?.call(newRating.round());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRTL = Directionality.of(context) == TextDirection.rtl;
|
||||
final double visualAlignmentOffset = 5.0;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
|
||||
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
|
||||
children: List.generate(widget.itemCount * 2 - 1, (i) {
|
||||
if (i.isOdd) {
|
||||
return SizedBox(width: widget.starPadding);
|
||||
}
|
||||
int index = i ~/ 2;
|
||||
bool filled = _currentRating > index;
|
||||
return widget.itemBuilder ??
|
||||
Icon(
|
||||
Icons.star_rounded,
|
||||
size: widget.itemSize,
|
||||
color: filled ? widget.filledColor : widget.unfilledColor,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_currentRating > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentRating = 0;
|
||||
});
|
||||
widget.onClearRating?.call();
|
||||
},
|
||||
child: Text(
|
||||
'rating_clear'.t(context: context),
|
||||
style: TextStyle(color: context.themeData.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.75,
|
||||
maxChildSize: 0.9,
|
||||
shouldCloseOnMinExtent: false,
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
@@ -38,13 +38,8 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
throw Exception('User must be logged in to access archive');
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.map(users, ref.watch(mapStateProvider).toOptions());
|
||||
final bounds = ref.watch(mapStateProvider).bounds;
|
||||
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
final ThemeMode themeMode;
|
||||
final LatLngBounds bounds;
|
||||
final bool onlyFavorites;
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
required this.bounds,
|
||||
this.onlyFavorites = false,
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
});
|
||||
const MapState({required this.bounds});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MapState other) {
|
||||
@@ -34,31 +15,9 @@ class MapState {
|
||||
@override
|
||||
int get hashCode => bounds.hashCode;
|
||||
|
||||
MapState copyWith({
|
||||
LatLngBounds? bounds,
|
||||
ThemeMode? themeMode,
|
||||
bool? onlyFavorites,
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
onlyFavorites: onlyFavorites ?? this.onlyFavorites,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
);
|
||||
MapState copyWith({LatLngBounds? bounds}) {
|
||||
return MapState(bounds: bounds ?? this.bounds);
|
||||
}
|
||||
|
||||
TimelineMapOptions toOptions() => TimelineMapOptions(
|
||||
bounds: bounds,
|
||||
onlyFavorites: onlyFavorites,
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
);
|
||||
}
|
||||
|
||||
class MapStateNotifier extends Notifier<MapState> {
|
||||
@@ -72,50 +31,11 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode mode) {
|
||||
// TODO: Remove this line when map theme provider is removed
|
||||
// Until then, keep both in sync as MapThemeOverride uses map state provider
|
||||
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
|
||||
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
|
||||
state = state.copyWith(themeMode: mode);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly);
|
||||
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived);
|
||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void switchWithPartners(bool isWithPartners) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners);
|
||||
state = state.copyWith(withPartners: isWithPartners);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeDays) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays);
|
||||
state = state.copyWith(relativeDays: relativeDays);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final appSettingsService = ref.read(appSettingsServiceProvider);
|
||||
return MapState(
|
||||
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
|
||||
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
|
||||
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
|
||||
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
);
|
||||
}
|
||||
MapState build() => MapState(
|
||||
// TODO: set default bounds
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
// This provider watches the markers from the map service and serves the markers.
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
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';
|
||||
@@ -53,19 +51,11 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final _reloadMutex = AsyncMutex();
|
||||
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventSubscription = EventStream.shared.listen<MapMarkerReloadEvent>(_onEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncer.dispose();
|
||||
bottomSheetOffset.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -73,8 +63,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
mapController = controller;
|
||||
}
|
||||
|
||||
void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true));
|
||||
|
||||
Future<void> onMapReady() async {
|
||||
final controller = mapController;
|
||||
if (controller == null) {
|
||||
@@ -110,7 +98,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
);
|
||||
}
|
||||
|
||||
_debouncer.run(() => setBounds(forceReload: true));
|
||||
_debouncer.run(setBounds);
|
||||
controller.addListener(onMapMoved);
|
||||
}
|
||||
|
||||
@@ -122,7 +110,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
_debouncer.run(setBounds);
|
||||
}
|
||||
|
||||
Future<void> setBounds({bool forceReload = false}) async {
|
||||
Future<void> setBounds() async {
|
||||
final controller = mapController;
|
||||
if (controller == null || !mounted) {
|
||||
return;
|
||||
@@ -139,7 +127,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final bounds = await controller.getVisibleRegion();
|
||||
unawaited(
|
||||
_reloadMutex.run(() async {
|
||||
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
|
||||
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
|
||||
final markers = await ref.read(mapMarkerProvider(bounds).future);
|
||||
await reloadMarkers(markers);
|
||||
}
|
||||
@@ -215,7 +203,7 @@ class _Map extends StatelessWidget {
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onMapReady,
|
||||
attributionButtonPosition: AttributionButtonPosition.topRight,
|
||||
attributionButtonMargins: const Point(8, kToolbarHeight),
|
||||
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -256,7 +244,7 @@ class _DynamicMyLocationButton extends StatelessWidget {
|
||||
valueListenable: bottomSheetOffset,
|
||||
builder: (context, offset, child) {
|
||||
return Positioned(
|
||||
right: 20,
|
||||
right: 16,
|
||||
bottom: context.height * (offset - 0.02) + context.padding.bottom,
|
||||
child: AnimatedOpacity(
|
||||
opacity: offset < 0.8 ? 1 : 0,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
|
||||
|
||||
class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
const DriftMapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapState = ref.watch(mapStateProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
elevation: 0.0,
|
||||
shadowColor: Colors.transparent,
|
||||
color: Colors.transparent,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
MapThemePicker(
|
||||
themeMode: mapState.themeMode,
|
||||
onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode),
|
||||
),
|
||||
const Divider(height: 30, thickness: 1),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_only_show_favorites".t(context: context),
|
||||
selected: mapState.onlyFavorites,
|
||||
onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly),
|
||||
),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_include_show_archived".t(context: context),
|
||||
selected: mapState.includeArchived,
|
||||
onChanged: (includeArchive) =>
|
||||
ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive),
|
||||
),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_include_show_partners".t(context: context),
|
||||
selected: mapState.withPartners,
|
||||
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
@@ -156,11 +157,11 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
]);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(syncCloudIds(_ref), "syncCloudIds"),
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
@@ -172,6 +176,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
isAdmin: user.isAdmin,
|
||||
);
|
||||
|
||||
// TODO: Temporarily run cloud Id sync here until we have a better place to do it
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncRemote().then((success) {
|
||||
if (success) {
|
||||
return syncCloudIds(_ref);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,6 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
onHashingStart: syncStatusNotifier.startHashJob,
|
||||
onHashingComplete: syncStatusNotifier.completeHashJob,
|
||||
onHashingError: syncStatusNotifier.errorHashJob,
|
||||
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
|
||||
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
|
||||
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
|
||||
);
|
||||
ref.onDispose(manager.cancel);
|
||||
return manager;
|
||||
|
||||
@@ -1,150 +1,65 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
|
||||
class CleanupState {
|
||||
final DateTime? selectedDate;
|
||||
final List<LocalAsset> assetsToDelete;
|
||||
final int totalBytes;
|
||||
final bool isScanning;
|
||||
final bool isDeleting;
|
||||
final AssetKeepType keepMediaType;
|
||||
final AssetFilterType filterType;
|
||||
final bool keepFavorites;
|
||||
final Set<String> keepAlbumIds;
|
||||
|
||||
const CleanupState({
|
||||
this.selectedDate,
|
||||
this.assetsToDelete = const [],
|
||||
this.totalBytes = 0,
|
||||
this.isScanning = false,
|
||||
this.isDeleting = false,
|
||||
this.keepMediaType = AssetKeepType.none,
|
||||
this.filterType = AssetFilterType.all,
|
||||
this.keepFavorites = true,
|
||||
this.keepAlbumIds = const {},
|
||||
});
|
||||
|
||||
CleanupState copyWith({
|
||||
DateTime? selectedDate,
|
||||
List<LocalAsset>? assetsToDelete,
|
||||
int? totalBytes,
|
||||
bool? isScanning,
|
||||
bool? isDeleting,
|
||||
AssetKeepType? keepMediaType,
|
||||
AssetFilterType? filterType,
|
||||
bool? keepFavorites,
|
||||
Set<String>? keepAlbumIds,
|
||||
}) {
|
||||
return CleanupState(
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
|
||||
totalBytes: totalBytes ?? this.totalBytes,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
isDeleting: isDeleting ?? this.isDeleting,
|
||||
keepMediaType: keepMediaType ?? this.keepMediaType,
|
||||
filterType: filterType ?? this.filterType,
|
||||
keepFavorites: keepFavorites ?? this.keepFavorites,
|
||||
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
|
||||
return CleanupNotifier(
|
||||
ref.watch(cleanupServiceProvider),
|
||||
ref.watch(currentUserProvider)?.id,
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
);
|
||||
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
|
||||
});
|
||||
|
||||
class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
final CleanupService _cleanupService;
|
||||
final String? _userId;
|
||||
final AppSettingsService _appSettingsService;
|
||||
|
||||
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) {
|
||||
_loadPersistedSettings();
|
||||
}
|
||||
|
||||
void _loadPersistedSettings() {
|
||||
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites);
|
||||
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType);
|
||||
final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds);
|
||||
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
|
||||
|
||||
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
|
||||
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
|
||||
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
|
||||
|
||||
state = state.copyWith(
|
||||
keepFavorites: keepFavorites,
|
||||
keepMediaType: keepMediaType,
|
||||
keepAlbumIds: keepAlbumIds,
|
||||
selectedDate: selectedDate,
|
||||
);
|
||||
}
|
||||
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
|
||||
|
||||
void setSelectedDate(DateTime? date) {
|
||||
state = state.copyWith(selectedDate: date, assetsToDelete: []);
|
||||
if (date != null) {
|
||||
final daysAgo = DateTime.now().difference(date).inDays;
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo);
|
||||
}
|
||||
}
|
||||
|
||||
void setKeepMediaType(AssetKeepType keepMediaType) {
|
||||
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index);
|
||||
void setFilterType(AssetFilterType filterType) {
|
||||
state = state.copyWith(filterType: filterType, assetsToDelete: []);
|
||||
}
|
||||
|
||||
void setKeepFavorites(bool keepFavorites) {
|
||||
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites);
|
||||
}
|
||||
|
||||
void toggleKeepAlbum(String albumId) {
|
||||
final newKeepAlbumIds = Set<String>.from(state.keepAlbumIds);
|
||||
if (newKeepAlbumIds.contains(albumId)) {
|
||||
newKeepAlbumIds.remove(albumId);
|
||||
} else {
|
||||
newKeepAlbumIds.add(albumId);
|
||||
}
|
||||
state = state.copyWith(keepAlbumIds: newKeepAlbumIds, assetsToDelete: []);
|
||||
_persistExcludedAlbumIds(newKeepAlbumIds);
|
||||
}
|
||||
|
||||
void setExcludedAlbumIds(Set<String> albumIds) {
|
||||
state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []);
|
||||
_persistExcludedAlbumIds(albumIds);
|
||||
}
|
||||
|
||||
void _persistExcludedAlbumIds(Set<String> albumIds) {
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(','));
|
||||
}
|
||||
|
||||
void cleanupStaleAlbumIds(Set<String> existingAlbumIds) {
|
||||
final staleIds = state.keepAlbumIds.difference(existingAlbumIds);
|
||||
if (staleIds.isNotEmpty) {
|
||||
final cleanedIds = state.keepAlbumIds.intersection(existingAlbumIds);
|
||||
state = state.copyWith(keepAlbumIds: cleanedIds);
|
||||
_persistExcludedAlbumIds(cleanedIds);
|
||||
}
|
||||
}
|
||||
|
||||
void applyDefaultAlbumSelections(List<(String id, String name)> albums) {
|
||||
final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized);
|
||||
if (isInitialized) return;
|
||||
|
||||
final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums);
|
||||
|
||||
if (toKeep.isNotEmpty) {
|
||||
final keepAlbumIds = {...state.keepAlbumIds, ...toKeep};
|
||||
state = state.copyWith(keepAlbumIds: keepAlbumIds);
|
||||
_persistExcludedAlbumIds(keepAlbumIds);
|
||||
}
|
||||
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true);
|
||||
}
|
||||
|
||||
Future<void> scanAssets() async {
|
||||
@@ -154,15 +69,13 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
|
||||
state = state.copyWith(isScanning: true);
|
||||
try {
|
||||
final result = await _cleanupService.getRemovalCandidates(
|
||||
final assets = await _cleanupService.getRemovalCandidates(
|
||||
_userId,
|
||||
state.selectedDate!,
|
||||
keepMediaType: state.keepMediaType,
|
||||
filterType: state.filterType,
|
||||
keepFavorites: state.keepFavorites,
|
||||
keepAlbumIds: state.keepAlbumIds,
|
||||
);
|
||||
|
||||
state = state.copyWith(assetsToDelete: result.assets, totalBytes: result.totalBytes, isScanning: false);
|
||||
state = state.copyWith(assetsToDelete: assets, isScanning: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isScanning: false);
|
||||
rethrow;
|
||||
@@ -188,7 +101,6 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
// Only reset transient state, keep the persisted filter settings
|
||||
state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false);
|
||||
state = const CleanupState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,22 +359,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> updateRating(ActionSource source, int rating) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
if (ids.length != 1) {
|
||||
_logger.warning('updateRating called with multiple assets, expected single asset');
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update');
|
||||
}
|
||||
|
||||
try {
|
||||
final isUpdated = await _service.updateRating(ids.first, rating);
|
||||
return ActionResult(count: 1, success: isUpdated);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to update rating for asset', error, stack);
|
||||
return ActionResult(count: 1, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> stack(String userId, ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/domain/services/map.service.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
|
||||
@@ -15,11 +13,7 @@ final mapServiceProvider = Provider<MapService>(
|
||||
throw Exception('User must be logged in to access map');
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
: [user.id];
|
||||
|
||||
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
|
||||
final mapService = ref.watch(mapFactoryProvider).remote(user.id);
|
||||
return mapService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
||||
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
|
||||
final repository = ref.watch(userMetadataRepository);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) return [];
|
||||
return repository.getUserMetadata(user.id);
|
||||
});
|
||||
|
||||
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
|
||||
final metadataList = await ref.watch(userMetadataProvider.future);
|
||||
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
|
||||
return metadataWithPrefs.preferences;
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ class SyncStatusState {
|
||||
final SyncStatus remoteSyncStatus;
|
||||
final SyncStatus localSyncStatus;
|
||||
final SyncStatus hashJobStatus;
|
||||
final SyncStatus cloudIdSyncStatus;
|
||||
|
||||
final String? errorMessage;
|
||||
|
||||
@@ -29,7 +28,6 @@ class SyncStatusState {
|
||||
this.remoteSyncStatus = SyncStatus.idle,
|
||||
this.localSyncStatus = SyncStatus.idle,
|
||||
this.hashJobStatus = SyncStatus.idle,
|
||||
this.cloudIdSyncStatus = SyncStatus.idle,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@@ -37,14 +35,12 @@ class SyncStatusState {
|
||||
SyncStatus? remoteSyncStatus,
|
||||
SyncStatus? localSyncStatus,
|
||||
SyncStatus? hashJobStatus,
|
||||
SyncStatus? cloudIdSyncStatus,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return SyncStatusState(
|
||||
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
|
||||
localSyncStatus: localSyncStatus ?? this.localSyncStatus,
|
||||
hashJobStatus: hashJobStatus ?? this.hashJobStatus,
|
||||
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
@@ -52,7 +48,6 @@ class SyncStatusState {
|
||||
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
|
||||
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
|
||||
bool get isHashing => hashJobStatus == SyncStatus.syncing;
|
||||
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -61,12 +56,11 @@ class SyncStatusState {
|
||||
other.remoteSyncStatus == remoteSyncStatus &&
|
||||
other.localSyncStatus == localSyncStatus &&
|
||||
other.hashJobStatus == hashJobStatus &&
|
||||
other.cloudIdSyncStatus == cloudIdSyncStatus &&
|
||||
other.errorMessage == errorMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
|
||||
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage);
|
||||
}
|
||||
|
||||
class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
@@ -77,7 +71,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
remoteSyncStatus: SyncStatus.idle,
|
||||
localSyncStatus: SyncStatus.idle,
|
||||
hashJobStatus: SyncStatus.idle,
|
||||
cloudIdSyncStatus: SyncStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,18 +109,6 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
|
||||
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
|
||||
void completeHashJob() => setHashJobStatus(SyncStatus.success);
|
||||
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
|
||||
|
||||
///
|
||||
/// Cloud ID Sync Job
|
||||
///
|
||||
|
||||
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
|
||||
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
|
||||
}
|
||||
|
||||
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
|
||||
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
|
||||
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
|
||||
}
|
||||
|
||||
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);
|
||||
|
||||
@@ -101,10 +101,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> updateDescription(String assetId, String description) {
|
||||
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
||||
}
|
||||
|
||||
Future<void> updateRating(String assetId, int rating) {
|
||||
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -214,14 +214,6 @@ class ActionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> updateRating(String assetId, int rating) async {
|
||||
// update remote first, then local to ensure consistency
|
||||
await _assetApiRepository.updateRating(assetId, rating);
|
||||
await _remoteAssetRepository.updateRating(assetId, rating);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||
final stack = await _assetApiRepository.stack(remoteIds);
|
||||
await _remoteAssetRepository.stack(userId, stack);
|
||||
|
||||
@@ -54,12 +54,7 @@ enum AppSettingsEnum<T> {
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
|
||||
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
|
||||
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
|
||||
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1),
|
||||
cleanupDefaultsInitialized<bool>(StoreKey.cleanupDefaultsInitialized, null, false);
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
@@ -14,19 +15,17 @@ class CleanupService {
|
||||
|
||||
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
|
||||
|
||||
Future<RemovalCandidatesResult> getRemovalCandidates(
|
||||
Future<List<LocalAsset>> getRemovalCandidates(
|
||||
String userId,
|
||||
DateTime cutoffDate, {
|
||||
AssetKeepType keepMediaType = AssetKeepType.none,
|
||||
AssetFilterType filterType = AssetFilterType.all,
|
||||
bool keepFavorites = true,
|
||||
Set<String> keepAlbumIds = const {},
|
||||
}) {
|
||||
return _localAssetRepository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: keepMediaType,
|
||||
filterType: filterType,
|
||||
keepFavorites: keepFavorites,
|
||||
keepAlbumIds: keepAlbumIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,18 +42,4 @@ class CleanupService {
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
|
||||
Set<String> getDefaultKeepAlbumIds(List<(String id, String name)> albums) {
|
||||
const messagingApps = ['whatsapp', 'telegram', 'signal', 'messenger', 'viber', 'wechat', 'line'];
|
||||
|
||||
final toKeep = <String>{};
|
||||
for (final (id, name) in albums) {
|
||||
final albumName = name.toLowerCase();
|
||||
if (messagingApps.any((app) => albumName.contains(app))) {
|
||||
toKeep.add(id);
|
||||
}
|
||||
}
|
||||
return toKeep;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
|
||||
if (version < 20 && Store.isBetaTimelineEnabled) {
|
||||
await _syncLocalAlbumIsIosSharedAlbum(drift);
|
||||
await _backfillAssetExifWidthHeight(drift);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
@@ -282,22 +281,6 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
|
||||
try {
|
||||
await db.customStatement('''
|
||||
UPDATE remote_exif_entity AS remote_exif
|
||||
SET width = asset.width,
|
||||
height = asset.height
|
||||
FROM remote_asset_entity AS asset
|
||||
WHERE remote_exif.asset_id = asset.id;
|
||||
''');
|
||||
|
||||
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||
try {
|
||||
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
|
||||
@@ -88,14 +87,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
|
||||
}
|
||||
|
||||
buildFreeUpSpaceButton() {
|
||||
return buildActionButton(
|
||||
Icons.cleaning_services_outlined,
|
||||
"free_up_space",
|
||||
() => context.pushRoute(SettingsSubRoute(section: SettingSection.freeUpSpace)),
|
||||
);
|
||||
}
|
||||
|
||||
buildAppLogButton() {
|
||||
return buildActionButton(
|
||||
Icons.assignment_outlined,
|
||||
@@ -280,7 +271,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
const AppBarServerInfo(),
|
||||
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
|
||||
buildAppLogButton(),
|
||||
buildFreeUpSpaceButton(),
|
||||
buildSettingButton(),
|
||||
buildSignOutButton(),
|
||||
buildFooter(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -13,7 +14,7 @@ class MapSettingsListTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile.adaptive(
|
||||
activeThumbColor: context.primaryColor,
|
||||
title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
|
||||
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(),
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class MapTimeDropDown extends StatelessWidget {
|
||||
final int relativeTime;
|
||||
@@ -13,47 +11,41 @@ class MapTimeDropDown extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 28.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"date_range".t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (_, constraints) => DropdownMenu(
|
||||
width: constraints.maxWidth * 0.9,
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "all".tr()),
|
||||
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()),
|
||||
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
|
||||
DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
|
||||
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
|
||||
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".t(context: context),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
@@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"map_settings_theme_settings".t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
"map_settings_theme_settings",
|
||||
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
@@ -63,9 +62,6 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
double? get initialScale;
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
@@ -145,9 +141,6 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
ScaleBoundaries? scaleBoundaries;
|
||||
|
||||
late void Function(Offset)? _animatePosition;
|
||||
late void Function(double)? _animateScale;
|
||||
late void Function(double)? _animateRotation;
|
||||
@@ -318,7 +311,4 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
|
||||
@override
|
||||
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
|
||||
}
|
||||
|
||||
@@ -108,17 +108,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
}
|
||||
}
|
||||
|
||||
// Should be called only when _imageSize is not null
|
||||
ScaleBoundaries get scaleBoundaries {
|
||||
return ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
|
||||
@@ -144,7 +133,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
_lastStack = null;
|
||||
|
||||
_didLoadSynchronously = synchronousCall;
|
||||
widget.controller.scaleBoundaries = scaleBoundaries;
|
||||
}
|
||||
|
||||
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||
@@ -216,6 +204,14 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
|
||||
@@ -55,7 +55,6 @@ class ExploreGrid extends StatelessWidget {
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
|
||||
class StarRatingPicker extends HookWidget {
|
||||
const StarRatingPicker({super.key, required this.onSelect, this.filter});
|
||||
final Function(SearchRatingFilter) onSelect;
|
||||
final SearchRatingFilter? filter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedRating = useState(filter);
|
||||
|
||||
return RadioGroup(
|
||||
groupValue: selectedRating.value?.rating,
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue == null) return;
|
||||
final newFilter = SearchRatingFilter(rating: newValue);
|
||||
selectedRating.value = newFilter;
|
||||
onSelect(newFilter);
|
||||
},
|
||||
child: Column(
|
||||
children: List.generate(
|
||||
6,
|
||||
(index) => RadioListTile<int>(
|
||||
key: Key("star_$index"),
|
||||
title: Text('rating_count'.t(args: {'count': (index)})),
|
||||
value: index,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
];
|
||||
|
||||
return SettingsSubPageScaffold(settings: advancedSettings);
|
||||
|
||||
@@ -3,16 +3,13 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
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/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/cleanup.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
|
||||
const FreeUpSpaceSettings({super.key});
|
||||
@@ -24,25 +21,6 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget {
|
||||
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
CleanupStep _currentStep = CleanupStep.selectDate;
|
||||
bool _hasScanned = false;
|
||||
bool _isKeepSettingsExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeAlbumDefaults();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeAlbumDefaults() async {
|
||||
final albums = await ref.read(localAlbumProvider.future);
|
||||
final existingAlbumIds = albums.map((a) => a.id).toSet();
|
||||
final albumsWithNames = albums.map((a) => (a.id, a.name)).toList();
|
||||
|
||||
final notifier = ref.read(cleanupProvider.notifier);
|
||||
notifier.applyDefaultAlbumSelections(albumsWithNames);
|
||||
notifier.cleanupStaleAlbumIds(existingAlbumIds);
|
||||
}
|
||||
|
||||
void _resetState() {
|
||||
ref.read(cleanupProvider.notifier).reset();
|
||||
@@ -57,16 +35,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
}
|
||||
|
||||
if (state.selectedDate != null) {
|
||||
return CleanupStep.scan;
|
||||
return CleanupStep.filterOptions;
|
||||
}
|
||||
|
||||
return CleanupStep.selectDate;
|
||||
}
|
||||
|
||||
void _goToFiltersStep() {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
setState(() => _currentStep = CleanupStep.filterOptions);
|
||||
}
|
||||
|
||||
void _goToScanStep() {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
setState(() => _currentStep = CleanupStep.scan);
|
||||
_scanAssets();
|
||||
}
|
||||
|
||||
void _setPresetDate(int daysAgo) {
|
||||
@@ -101,17 +83,9 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
|
||||
if (picked != null) {
|
||||
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
|
||||
setState(() => _hasScanned = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeepSettingsChanged() {
|
||||
setState(() {
|
||||
_hasScanned = false;
|
||||
_currentStep = CleanupStep.scan;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scanAssets() async {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
|
||||
@@ -153,11 +127,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
context: context,
|
||||
builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
context.router.popUntilRoot();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _currentStep = CleanupStep.selectDate);
|
||||
@@ -176,7 +145,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
|
||||
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
|
||||
);
|
||||
|
||||
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
|
||||
switch (stepState) {
|
||||
case StepState.complete:
|
||||
@@ -206,38 +174,28 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
}
|
||||
|
||||
final step1State = hasDate ? StepState.complete : StepState.indexed;
|
||||
final step2State = hasAssets
|
||||
final step2State = hasDate ? StepState.complete : StepState.disabled;
|
||||
final step3State = hasAssets
|
||||
? StepState.complete
|
||||
: hasDate
|
||||
? StepState.indexed
|
||||
: StepState.disabled;
|
||||
final step3State = hasAssets ? StepState.indexed : StepState.disabled;
|
||||
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
|
||||
|
||||
final hasKeepSettings =
|
||||
state.keepFavorites || state.keepAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none;
|
||||
|
||||
String getKeepSettingsSummary() {
|
||||
String getFilterSubtitle() {
|
||||
final parts = <String>[];
|
||||
|
||||
if (state.keepMediaType == AssetKeepType.photosOnly) {
|
||||
parts.add('all_photos'.t(context: context));
|
||||
} else if (state.keepMediaType == AssetKeepType.videosOnly) {
|
||||
parts.add('all_videos'.t(context: context));
|
||||
switch (state.filterType) {
|
||||
case AssetFilterType.all:
|
||||
parts.add('all'.t(context: context));
|
||||
case AssetFilterType.photosOnly:
|
||||
parts.add('photos_only'.t(context: context));
|
||||
case AssetFilterType.videosOnly:
|
||||
parts.add('videos_only'.t(context: context));
|
||||
}
|
||||
|
||||
if (state.keepFavorites) {
|
||||
parts.add('favorites'.t(context: context).toLowerCase());
|
||||
parts.add('keep_favorites'.t(context: context));
|
||||
}
|
||||
|
||||
if (state.keepAlbumIds.isNotEmpty) {
|
||||
parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()}));
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return 'none'.t(context: context);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
@@ -262,126 +220,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
),
|
||||
),
|
||||
|
||||
// Keep on device settings card
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(
|
||||
color: hasKeepSettings
|
||||
? context.colorScheme.primary.withValues(alpha: 0.5)
|
||||
: context.colorScheme.outlineVariant,
|
||||
width: hasKeepSettings ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
color: hasKeepSettings
|
||||
? context.colorScheme.primaryContainer.withValues(alpha: 0.15)
|
||||
: context.colorScheme.surfaceContainerLow,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: _isKeepSettingsExpanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() => _isKeepSettingsExpanded = expanded);
|
||||
},
|
||||
leading: Icon(
|
||||
hasKeepSettings ? Icons.bookmark : Icons.bookmark_border,
|
||||
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
'keep_on_device'.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasKeepSettings ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
hasKeepSettings
|
||||
? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()})
|
||||
: 'keep_on_device_hint'.t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('keep_description'.t(context: context), style: subtitleStyle),
|
||||
const SizedBox(height: 4),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'keep_favorites'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
|
||||
value: state.keepFavorites,
|
||||
onChanged: (value) {
|
||||
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_KeepAlbumsSection(
|
||||
albumIds: state.keepAlbumIds,
|
||||
onAlbumToggled: (albumId) {
|
||||
ref.read(cleanupProvider.notifier).toggleKeepAlbum(albumId);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'always_keep'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SegmentedButton<AssetKeepType>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
const ButtonSegment(value: AssetKeepType.none, label: Text('—')),
|
||||
ButtonSegment(
|
||||
value: AssetKeepType.photosOnly,
|
||||
label: Text('photos'.t(context: context)),
|
||||
icon: const Icon(Icons.photo),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetKeepType.videosOnly,
|
||||
label: Text('videos'.t(context: context)),
|
||||
icon: const Icon(Icons.videocam),
|
||||
),
|
||||
],
|
||||
selected: {state.keepMediaType},
|
||||
onSelectionChanged: (selection) {
|
||||
ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
if (state.keepMediaType != AssetKeepType.none) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.keepMediaType == AssetKeepType.photosOnly
|
||||
? 'always_keep_photos_hint'.t(context: context)
|
||||
: 'always_keep_videos_hint'.t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Stepper(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
currentStep: _currentStep.index,
|
||||
@@ -476,7 +314,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: hasDate ? _goToScanStep : null,
|
||||
onPressed: hasDate ? () => _goToFiltersStep() : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text('continue'.t(context: context)),
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
|
||||
@@ -487,11 +325,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
state: step1State,
|
||||
),
|
||||
|
||||
// Step 2: Scan Assets
|
||||
// Step 2: Select Filter Options
|
||||
Step(
|
||||
stepStyle: styleForState(step2State),
|
||||
title: Text(
|
||||
'scan'.t(context: context),
|
||||
'filter_options'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step2State == StepState.complete
|
||||
@@ -501,20 +339,96 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
: context.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: hasDate
|
||||
? Text(
|
||||
getFilterSubtitle(),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<AssetFilterType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.all,
|
||||
label: Text('all'.t(context: context)),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.photosOnly,
|
||||
label: Text('photos'.t(context: context)),
|
||||
icon: const Icon(Icons.photo),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.videosOnly,
|
||||
label: Text('videos'.t(context: context)),
|
||||
icon: const Icon(Icons.videocam),
|
||||
),
|
||||
],
|
||||
selected: {state.filterType},
|
||||
onSelectionChanged: (selection) {
|
||||
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
|
||||
setState(() => _hasScanned = false);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'keep_favorites'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
subtitle: Text(
|
||||
'keep_favorites_description'.t(context: context),
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
|
||||
),
|
||||
),
|
||||
value: state.keepFavorites,
|
||||
onChanged: (value) {
|
||||
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
|
||||
setState(() => _hasScanned = false);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _goToScanStep,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text('continue'.t(context: context)),
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
|
||||
),
|
||||
],
|
||||
),
|
||||
isActive: hasDate,
|
||||
state: step2State,
|
||||
),
|
||||
|
||||
// Step 3: Scan Assets
|
||||
Step(
|
||||
stepStyle: styleForState(step3State),
|
||||
title: Text(
|
||||
'scan'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step3State == StepState.complete
|
||||
? context.colorScheme.primary
|
||||
: step3State == StepState.disabled
|
||||
? context.colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: context.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: _hasScanned
|
||||
? Text(
|
||||
state.totalBytes > 0
|
||||
? 'cleanup_found_assets_with_size'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'size': formatBytes(state.totalBytes),
|
||||
},
|
||||
)
|
||||
: 'cleanup_found_assets'.t(
|
||||
context: context,
|
||||
args: {'count': state.assetsToDelete.length.toString()},
|
||||
),
|
||||
'cleanup_found_assets'.t(
|
||||
context: context,
|
||||
args: {'count': state.assetsToDelete.length.toString()},
|
||||
),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: state.assetsToDelete.isNotEmpty
|
||||
? context.colorScheme.primary
|
||||
@@ -589,17 +503,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
],
|
||||
),
|
||||
isActive: hasDate,
|
||||
state: step2State,
|
||||
state: step3State,
|
||||
),
|
||||
|
||||
// Step 3: Delete Assets
|
||||
// Step 4: Delete Assets
|
||||
Step(
|
||||
stepStyle: styleForState(step3State, isDestructive: true),
|
||||
stepStyle: styleForState(step4State, isDestructive: true),
|
||||
title: Text(
|
||||
'move_to_device_trash'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step3State == StepState.disabled
|
||||
color: step4State == StepState.disabled
|
||||
? context.colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: context.colorScheme.error,
|
||||
),
|
||||
@@ -615,20 +529,15 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: hasAssets
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'cleanup_step4_summary'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'date': DateFormat.yMMMd().format(state.selectedDate!),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||
),
|
||||
],
|
||||
? Text(
|
||||
'cleanup_step4_summary'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'date': DateFormat.yMMMd().format(state.selectedDate!),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -664,11 +573,10 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
],
|
||||
),
|
||||
isActive: hasAssets,
|
||||
state: step3State,
|
||||
state: step4State,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -793,107 +701,3 @@ class _DatePresetCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlbumsSection extends ConsumerWidget {
|
||||
final Set<String> albumIds;
|
||||
final ValueChanged<String> onAlbumToggled;
|
||||
|
||||
const _KeepAlbumsSection({required this.albumIds, required this.onAlbumToggled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumsAsync = ref.watch(localAlbumProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'keep_albums'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
albumsAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (error, stack) => Text(
|
||||
'error_loading_albums'.t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||
),
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return Text(
|
||||
'no_albums_found'.t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colorScheme.outlineVariant),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
final isSelected = albumIds.contains(album.id);
|
||||
return _AlbumTile(album: album, isSelected: isSelected, onToggle: () => onAlbumToggled(album.id));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (albumIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumTile extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
final bool isSelected;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _AlbumTile({required this.album, required this.isSelected, required this.onToggle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||
leading: Icon(
|
||||
isSelected ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: isSelected ? context.colorScheme.primary : null),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
album.assetCount.toString(),
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
onTap: onToggle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1691,7 +1691,7 @@ class AssetsApi {
|
||||
|
||||
/// View asset thumbnail
|
||||
///
|
||||
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
|
||||
/// Retrieve the thumbnail image for the specified asset.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
@@ -1747,7 +1747,7 @@ class AssetsApi {
|
||||
|
||||
/// View asset thumbnail
|
||||
///
|
||||
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
|
||||
/// Retrieve the thumbnail image for the specified asset.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
-3
@@ -23,14 +23,12 @@ class AssetMediaSize {
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const original = AssetMediaSize._(r'original');
|
||||
static const fullsize = AssetMediaSize._(r'fullsize');
|
||||
static const preview = AssetMediaSize._(r'preview');
|
||||
static const thumbnail = AssetMediaSize._(r'thumbnail');
|
||||
|
||||
/// List of all possible values in this [enum][AssetMediaSize].
|
||||
static const values = <AssetMediaSize>[
|
||||
original,
|
||||
fullsize,
|
||||
preview,
|
||||
thumbnail,
|
||||
@@ -72,7 +70,6 @@ class AssetMediaSizeTypeTransformer {
|
||||
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'original': return AssetMediaSize.original;
|
||||
case r'fullsize': return AssetMediaSize.fullsize;
|
||||
case r'preview': return AssetMediaSize.preview;
|
||||
case r'thumbnail': return AssetMediaSize.thumbnail;
|
||||
|
||||
Generated
-3
@@ -72,7 +72,6 @@ class Permission {
|
||||
static const facePeriodRead = Permission._(r'face.read');
|
||||
static const facePeriodUpdate = Permission._(r'face.update');
|
||||
static const facePeriodDelete = Permission._(r'face.delete');
|
||||
static const folderPeriodRead = Permission._(r'folder.read');
|
||||
static const jobPeriodCreate = Permission._(r'job.create');
|
||||
static const jobPeriodRead = Permission._(r'job.read');
|
||||
static const libraryPeriodCreate = Permission._(r'library.create');
|
||||
@@ -231,7 +230,6 @@ class Permission {
|
||||
facePeriodRead,
|
||||
facePeriodUpdate,
|
||||
facePeriodDelete,
|
||||
folderPeriodRead,
|
||||
jobPeriodCreate,
|
||||
jobPeriodRead,
|
||||
libraryPeriodCreate,
|
||||
@@ -425,7 +423,6 @@ class PermissionTypeTransformer {
|
||||
case r'face.read': return Permission.facePeriodRead;
|
||||
case r'face.update': return Permission.facePeriodUpdate;
|
||||
case r'face.delete': return Permission.facePeriodDelete;
|
||||
case r'folder.read': return Permission.folderPeriodRead;
|
||||
case r'job.create': return Permission.jobPeriodCreate;
|
||||
case r'job.read': return Permission.jobPeriodRead;
|
||||
case r'library.create': return Permission.libraryPeriodCreate;
|
||||
|
||||
@@ -15,7 +15,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
SystemConfigGeneratedFullsizeImageDto({
|
||||
required this.enabled,
|
||||
required this.format,
|
||||
this.progressive = false,
|
||||
required this.quality,
|
||||
});
|
||||
|
||||
@@ -23,8 +22,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
|
||||
ImageFormat format;
|
||||
|
||||
bool progressive;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
@@ -33,7 +30,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
|
||||
other.enabled == enabled &&
|
||||
other.format == format &&
|
||||
other.progressive == progressive &&
|
||||
other.quality == quality;
|
||||
|
||||
@override
|
||||
@@ -41,17 +37,15 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(format.hashCode) +
|
||||
(progressive.hashCode) +
|
||||
(quality.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]';
|
||||
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'format'] = this.format;
|
||||
json[r'progressive'] = this.progressive;
|
||||
json[r'quality'] = this.quality;
|
||||
return json;
|
||||
}
|
||||
@@ -67,7 +61,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
return SystemConfigGeneratedFullsizeImageDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
format: ImageFormat.fromJson(json[r'format'])!,
|
||||
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,12 @@ class SystemConfigGeneratedImageDto {
|
||||
/// Returns a new [SystemConfigGeneratedImageDto] instance.
|
||||
SystemConfigGeneratedImageDto({
|
||||
required this.format,
|
||||
this.progressive = false,
|
||||
required this.quality,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
ImageFormat format;
|
||||
|
||||
bool progressive;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
@@ -33,7 +30,6 @@ class SystemConfigGeneratedImageDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
|
||||
other.format == format &&
|
||||
other.progressive == progressive &&
|
||||
other.quality == quality &&
|
||||
other.size == size;
|
||||
|
||||
@@ -41,17 +37,15 @@ class SystemConfigGeneratedImageDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(format.hashCode) +
|
||||
(progressive.hashCode) +
|
||||
(quality.hashCode) +
|
||||
(size.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]';
|
||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'format'] = this.format;
|
||||
json[r'progressive'] = this.progressive;
|
||||
json[r'quality'] = this.quality;
|
||||
json[r'size'] = this.size;
|
||||
return json;
|
||||
@@ -67,7 +61,6 @@ class SystemConfigGeneratedImageDto {
|
||||
|
||||
return SystemConfigGeneratedImageDto(
|
||||
format: ImageFormat.fromJson(json[r'format'])!,
|
||||
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
size: mapValueOfType<int>(json, r'size')!,
|
||||
);
|
||||
|
||||
@@ -167,10 +167,10 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-1');
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-1');
|
||||
});
|
||||
|
||||
test('includes favorites when keepFavorites is false', () async {
|
||||
@@ -183,78 +183,14 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-favorite');
|
||||
expect(result.assets[0].isFavorite, true);
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-favorite');
|
||||
expect(candidates[0].isFavorite, true);
|
||||
});
|
||||
|
||||
test('keepMediaType photosOnly returns only videos for deletion', () async {
|
||||
// Photo - should be kept
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo',
|
||||
checksum: 'checksum-photo',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
|
||||
|
||||
// Video - should be deleted
|
||||
await insertLocalAsset(
|
||||
id: 'local-video',
|
||||
checksum: 'checksum-video',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.video,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.photosOnly,
|
||||
);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
expect(result.assets[0].type, AssetType.video);
|
||||
});
|
||||
|
||||
test('keepMediaType videosOnly returns only photos for deletion', () async {
|
||||
// Photo - should be deleted
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo',
|
||||
checksum: 'checksum-photo',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
|
||||
|
||||
// Video - should be kept
|
||||
await insertLocalAsset(
|
||||
id: 'local-video',
|
||||
checksum: 'checksum-video',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.video,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.videosOnly,
|
||||
);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-photo');
|
||||
expect(result.assets[0].type, AssetType.image);
|
||||
});
|
||||
|
||||
test('returns both photos and videos with keepMediaType.all', () async {
|
||||
test('filters by photos only', () async {
|
||||
// Photo
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo',
|
||||
@@ -275,10 +211,74 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none);
|
||||
final candidates = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
filterType: AssetFilterType.photosOnly,
|
||||
);
|
||||
|
||||
expect(result.assets.length, 2);
|
||||
final ids = result.assets.map((a) => a.id).toSet();
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-photo');
|
||||
expect(candidates[0].type, AssetType.image);
|
||||
});
|
||||
|
||||
test('filters by videos only', () async {
|
||||
// Photo
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo',
|
||||
checksum: 'checksum-photo',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
|
||||
|
||||
// Video
|
||||
await insertLocalAsset(
|
||||
id: 'local-video',
|
||||
checksum: 'checksum-video',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.video,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final candidates = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
filterType: AssetFilterType.videosOnly,
|
||||
);
|
||||
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-video');
|
||||
expect(candidates[0].type, AssetType.video);
|
||||
});
|
||||
|
||||
test('returns both photos and videos with filterType.all', () async {
|
||||
// Photo
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo',
|
||||
checksum: 'checksum-photo',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
|
||||
|
||||
// Video
|
||||
await insertLocalAsset(
|
||||
id: 'local-video',
|
||||
checksum: 'checksum-video',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.video,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
|
||||
|
||||
expect(candidates.length, 2);
|
||||
final ids = candidates.map((a) => a.id).toSet();
|
||||
expect(ids, containsAll(['local-photo', 'local-video']));
|
||||
});
|
||||
|
||||
@@ -311,10 +311,10 @@ void main() {
|
||||
await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared');
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-regular');
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-regular');
|
||||
});
|
||||
|
||||
test('includes assets at exact cutoff date', () async {
|
||||
@@ -327,10 +327,10 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-exact');
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-exact');
|
||||
});
|
||||
|
||||
test('returns empty list when no assets match criteria', () async {
|
||||
@@ -344,9 +344,9 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets, isEmpty);
|
||||
expect(candidates, isEmpty);
|
||||
});
|
||||
|
||||
test('handles multiple assets with same checksum', () async {
|
||||
@@ -367,10 +367,10 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets.length, 2);
|
||||
expect(result.assets.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
|
||||
expect(candidates.length, 2);
|
||||
expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
|
||||
});
|
||||
|
||||
test('includes assets not in any album', () async {
|
||||
@@ -384,10 +384,10 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-no-album');
|
||||
expect(candidates.length, 1);
|
||||
expect(candidates[0].id, 'local-no-album');
|
||||
});
|
||||
|
||||
test('excludes asset that is in both regular and iOS shared album', () async {
|
||||
@@ -409,9 +409,9 @@ void main() {
|
||||
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both');
|
||||
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both');
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets, isEmpty);
|
||||
expect(candidates, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes assets with null checksum (not backed up)', () async {
|
||||
@@ -430,218 +430,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
|
||||
|
||||
expect(result.assets, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes assets in user-excluded albums', () async {
|
||||
// Create two regular albums
|
||||
await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false);
|
||||
await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false);
|
||||
|
||||
// Asset in included album - should be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-in-included',
|
||||
checksum: 'checksum-included',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included');
|
||||
|
||||
// Asset in excluded album - should NOT be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-in-excluded',
|
||||
checksum: 'checksum-excluded',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded');
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-exclude'});
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-in-included');
|
||||
});
|
||||
|
||||
test('excludes assets that are in any of multiple excluded albums', () async {
|
||||
// Create multiple albums
|
||||
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
|
||||
await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false);
|
||||
await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false);
|
||||
|
||||
// Asset in album-1 (excluded) - should NOT be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: 'checksum-1',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
|
||||
|
||||
// Asset in album-2 (excluded) - should NOT be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-2',
|
||||
checksum: 'checksum-2',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2');
|
||||
|
||||
// Asset in album-3 (not excluded) - should be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-3',
|
||||
checksum: 'checksum-3',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepAlbumIds: {'album-1', 'album-2'},
|
||||
);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-3');
|
||||
});
|
||||
|
||||
test('excludes asset that is in both excluded and non-excluded album', () async {
|
||||
await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false);
|
||||
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
|
||||
|
||||
// Asset in BOTH albums - should be excluded because it's in an excluded album
|
||||
await insertLocalAsset(
|
||||
id: 'local-both',
|
||||
checksum: 'checksum-both',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both');
|
||||
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both');
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
|
||||
|
||||
expect(result.assets, isEmpty);
|
||||
});
|
||||
|
||||
test('includes all assets when excludedAlbumIds is empty', () async {
|
||||
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
|
||||
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: 'checksum-1',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
|
||||
|
||||
await insertLocalAsset(
|
||||
id: 'local-2',
|
||||
checksum: 'checksum-2',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
|
||||
|
||||
// Empty excludedAlbumIds should include all eligible assets
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {});
|
||||
|
||||
expect(result.assets.length, 2);
|
||||
});
|
||||
|
||||
test('excludes asset not in any album when album is excluded', () async {
|
||||
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
|
||||
|
||||
// Asset NOT in any album - should be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-no-album',
|
||||
checksum: 'checksum-no-album',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
|
||||
|
||||
// Asset in excluded album - should NOT be included
|
||||
await insertLocalAsset(
|
||||
id: 'local-in-excluded',
|
||||
checksum: 'checksum-in-excluded',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded');
|
||||
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-no-album');
|
||||
});
|
||||
|
||||
test('combines excludedAlbumIds with keepMediaType correctly', () async {
|
||||
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
|
||||
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
|
||||
|
||||
// Photo in excluded album - should NOT be included (album excluded)
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo-excluded',
|
||||
checksum: 'checksum-photo-excluded',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded');
|
||||
|
||||
// Video in regular album - should be included (keepMediaType photosOnly = delete videos)
|
||||
await insertLocalAsset(
|
||||
id: 'local-video',
|
||||
checksum: 'checksum-video',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.video,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video');
|
||||
|
||||
// Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos)
|
||||
await insertLocalAsset(
|
||||
id: 'local-photo-regular',
|
||||
checksum: 'checksum-photo-regular',
|
||||
createdAt: beforeCutoff,
|
||||
type: AssetType.image,
|
||||
isFavorite: false,
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular');
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.photosOnly,
|
||||
keepAlbumIds: {'album-excluded'},
|
||||
);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
expect(candidates, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function dart {
|
||||
}
|
||||
|
||||
function typescript {
|
||||
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
|
||||
pnpm --filter @immich/sdk install --frozen-lockfile
|
||||
pnpm --filter @immich/sdk build
|
||||
}
|
||||
|
||||
@@ -3173,7 +3173,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -3226,7 +3225,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "job.create",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -4279,7 +4277,7 @@
|
||||
},
|
||||
"/assets/{id}/thumbnail": {
|
||||
"get": {
|
||||
"description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.",
|
||||
"description": "Retrieve the thumbnail image for the specified asset.",
|
||||
"operationId": "viewAsset",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -14620,7 +14618,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "folder.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -14673,7 +14670,6 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "folder.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -16305,7 +16301,6 @@
|
||||
},
|
||||
"AssetMediaSize": {
|
||||
"enum": [
|
||||
"original",
|
||||
"fullsize",
|
||||
"preview",
|
||||
"thumbnail"
|
||||
@@ -18963,7 +18958,6 @@
|
||||
"face.read",
|
||||
"face.update",
|
||||
"face.delete",
|
||||
"folder.read",
|
||||
"job.create",
|
||||
"job.read",
|
||||
"library.create",
|
||||
@@ -22624,10 +22618,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"progressive": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -22650,10 +22640,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"progressive": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
|
||||
@@ -1538,12 +1538,10 @@ export type SystemConfigFFmpegDto = {
|
||||
export type SystemConfigGeneratedFullsizeImageDto = {
|
||||
enabled: boolean;
|
||||
format: ImageFormat;
|
||||
progressive?: boolean;
|
||||
quality: number;
|
||||
};
|
||||
export type SystemConfigGeneratedImageDto = {
|
||||
format: ImageFormat;
|
||||
progressive?: boolean;
|
||||
quality: number;
|
||||
size: number;
|
||||
};
|
||||
@@ -1877,210 +1875,6 @@ export type WorkflowUpdateDto = {
|
||||
name?: string;
|
||||
triggerType?: PluginTriggerType;
|
||||
};
|
||||
export type SyncAckV1 = {};
|
||||
export type SyncAlbumDeleteV1 = {
|
||||
albumId: string;
|
||||
};
|
||||
export type SyncAlbumToAssetDeleteV1 = {
|
||||
albumId: string;
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAlbumToAssetV1 = {
|
||||
albumId: string;
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAlbumUserDeleteV1 = {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumUserV1 = {
|
||||
albumId: string;
|
||||
role: AlbumUserRole;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncAlbumV1 = {
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
isActivityEnabled: boolean;
|
||||
name: string;
|
||||
order: AssetOrder;
|
||||
ownerId: string;
|
||||
thumbnailAssetId: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncAssetDeleteV1 = {
|
||||
assetId: string;
|
||||
};
|
||||
export type SyncAssetExifV1 = {
|
||||
assetId: string;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
dateTimeOriginal: string | null;
|
||||
description: string | null;
|
||||
exifImageHeight: number | null;
|
||||
exifImageWidth: number | null;
|
||||
exposureTime: string | null;
|
||||
fNumber: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
focalLength: number | null;
|
||||
fps: number | null;
|
||||
iso: number | null;
|
||||
latitude: number | null;
|
||||
lensModel: string | null;
|
||||
longitude: number | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
modifyDate: string | null;
|
||||
orientation: string | null;
|
||||
profileDescription: string | null;
|
||||
projectionType: string | null;
|
||||
rating: number | null;
|
||||
state: string | null;
|
||||
timeZone: string | null;
|
||||
};
|
||||
export type SyncAssetFaceDeleteV1 = {
|
||||
assetFaceId: string;
|
||||
};
|
||||
export type SyncAssetFaceV1 = {
|
||||
assetId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
sourceType: string;
|
||||
};
|
||||
export type SyncAssetMetadataDeleteV1 = {
|
||||
assetId: string;
|
||||
key: string;
|
||||
};
|
||||
export type SyncAssetMetadataV1 = {
|
||||
assetId: string;
|
||||
key: string;
|
||||
value: object;
|
||||
};
|
||||
export type SyncAssetV1 = {
|
||||
checksum: string;
|
||||
deletedAt: string | null;
|
||||
duration: string | null;
|
||||
fileCreatedAt: string | null;
|
||||
fileModifiedAt: string | null;
|
||||
height: number | null;
|
||||
id: string;
|
||||
isEdited: boolean;
|
||||
isFavorite: boolean;
|
||||
libraryId: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: string | null;
|
||||
originalFileName: string;
|
||||
ownerId: string;
|
||||
stackId: string | null;
|
||||
thumbhash: string | null;
|
||||
"type": AssetTypeEnum;
|
||||
visibility: AssetVisibility;
|
||||
width: number | null;
|
||||
};
|
||||
export type SyncAuthUserV1 = {
|
||||
avatarColor: (UserAvatarColor) | null;
|
||||
deletedAt: string | null;
|
||||
email: string;
|
||||
hasProfileImage: boolean;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
oauthId: string;
|
||||
pinCode: string | null;
|
||||
profileChangedAt: string;
|
||||
quotaSizeInBytes: number | null;
|
||||
quotaUsageInBytes: number;
|
||||
storageLabel: string | null;
|
||||
};
|
||||
export type SyncCompleteV1 = {};
|
||||
export type SyncMemoryAssetDeleteV1 = {
|
||||
assetId: string;
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryAssetV1 = {
|
||||
assetId: string;
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryDeleteV1 = {
|
||||
memoryId: string;
|
||||
};
|
||||
export type SyncMemoryV1 = {
|
||||
createdAt: string;
|
||||
data: object;
|
||||
deletedAt: string | null;
|
||||
hideAt: string | null;
|
||||
id: string;
|
||||
isSaved: boolean;
|
||||
memoryAt: string;
|
||||
ownerId: string;
|
||||
seenAt: string | null;
|
||||
showAt: string | null;
|
||||
"type": MemoryType;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncPartnerDeleteV1 = {
|
||||
sharedById: string;
|
||||
sharedWithId: string;
|
||||
};
|
||||
export type SyncPartnerV1 = {
|
||||
inTimeline: boolean;
|
||||
sharedById: string;
|
||||
sharedWithId: string;
|
||||
};
|
||||
export type SyncPersonDeleteV1 = {
|
||||
personId: string;
|
||||
};
|
||||
export type SyncPersonV1 = {
|
||||
birthDate: string | null;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
faceAssetId: string | null;
|
||||
id: string;
|
||||
isFavorite: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncResetV1 = {};
|
||||
export type SyncStackDeleteV1 = {
|
||||
stackId: string;
|
||||
};
|
||||
export type SyncStackV1 = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
primaryAssetId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SyncUserDeleteV1 = {
|
||||
userId: string;
|
||||
};
|
||||
export type SyncUserMetadataDeleteV1 = {
|
||||
key: UserMetadataKey;
|
||||
userId: string;
|
||||
};
|
||||
export type SyncUserMetadataV1 = {
|
||||
key: UserMetadataKey;
|
||||
userId: string;
|
||||
value: object;
|
||||
};
|
||||
export type SyncUserV1 = {
|
||||
avatarColor: (UserAvatarColor) | null;
|
||||
deletedAt: string | null;
|
||||
email: string;
|
||||
hasProfileImage: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
profileChangedAt: string;
|
||||
};
|
||||
/**
|
||||
* List all activities
|
||||
*/
|
||||
@@ -5730,7 +5524,6 @@ export enum Permission {
|
||||
FaceRead = "face.read",
|
||||
FaceUpdate = "face.update",
|
||||
FaceDelete = "face.delete",
|
||||
FolderRead = "folder.read",
|
||||
JobCreate = "job.create",
|
||||
JobRead = "job.read",
|
||||
LibraryCreate = "library.create",
|
||||
@@ -5867,7 +5660,6 @@ export enum MirrorAxis {
|
||||
Vertical = "vertical"
|
||||
}
|
||||
export enum AssetMediaSize {
|
||||
Original = "original",
|
||||
Fullsize = "fullsize",
|
||||
Preview = "preview",
|
||||
Thumbnail = "thumbnail"
|
||||
@@ -6145,8 +5937,3 @@ export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretPost = "client_secret_post",
|
||||
ClientSecretBasic = "client_secret_basic"
|
||||
}
|
||||
export enum UserMetadataKey {
|
||||
Preferences = "preferences",
|
||||
License = "license",
|
||||
Onboarding = "onboarding"
|
||||
}
|
||||
|
||||
Generated
+160
-532
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
|
||||
fi
|
||||
|
||||
cd /usr/src/app || exit
|
||||
|
||||
# Compile telemetry-preload so it can be required before the app starts.
|
||||
# This is necessary for OpenTelemetry http instrumentation to work correctly.
|
||||
mkdir -p dist
|
||||
pnpm --filter immich exec tsc src/telemetry-preload.ts \
|
||||
--outDir dist \
|
||||
--esModuleInterop \
|
||||
--module node16 \
|
||||
--moduleResolution node16 \
|
||||
--target ES2022 \
|
||||
--skipLibCheck
|
||||
|
||||
export NODE_OPTIONS="${NODE_OPTIONS} --require ./dist/telemetry-preload.js"
|
||||
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ else
|
||||
fi
|
||||
|
||||
if [ -f "${SERVER_HOME}/dist/main.js" ]; then
|
||||
exec node --require "${SERVER_HOME}/dist/telemetry-preload.js" "${SERVER_HOME}/dist/main.js" "$@"
|
||||
exec node "${SERVER_HOME}/dist/main.js" "$@"
|
||||
else
|
||||
echo "Error: ${SERVER_HOME}/dist/main.js not found"
|
||||
if [ "$IMMICH_ENV" = "development" ]; then
|
||||
|
||||
+1
-4
@@ -45,16 +45,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.210.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.210.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.58.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.56.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.62.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.210.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -65,7 +63,6 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^5.51.0",
|
||||
"bullmq-otel": "^1.1.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
|
||||
|
||||
export type SystemConfig = {
|
||||
export interface SystemConfig {
|
||||
backup: {
|
||||
database: {
|
||||
enabled: boolean;
|
||||
@@ -187,7 +187,7 @@ export type SystemConfig = {
|
||||
user: {
|
||||
deleteDelay: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
@@ -319,13 +319,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
@@ -333,7 +331,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
enabled: false,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
},
|
||||
newVersionCheck: {
|
||||
|
||||
@@ -147,8 +147,7 @@ export class AssetMediaController {
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'View asset thumbnail',
|
||||
description:
|
||||
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
|
||||
description: 'Retrieve the thumbnail image for the specified asset.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async viewAsset(
|
||||
@@ -203,7 +202,7 @@ export class AssetMediaController {
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Check existing assets',
|
||||
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
|
||||
|
||||
@@ -66,7 +66,7 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Post('jobs')
|
||||
@Authenticated({ permission: Permission.JobCreate })
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Run an asset job',
|
||||
|
||||
@@ -70,33 +70,5 @@ describe(SystemConfigController.name, () => {
|
||||
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('should accept config without optional progressive property', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
delete config.image.thumbnail.progressive;
|
||||
delete config.image.preview.progressive;
|
||||
delete config.image.fullsize.progressive;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept config with progressive set to true', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.image.thumbnail.progressive = true;
|
||||
config.image.preview.progressive = true;
|
||||
config.image.fullsize.progressive = true;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should reject invalid progressive value', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
(config.image.thumbnail.progressive as any) = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { ApiTag } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ViewController {
|
||||
constructor(private service: ViewService) {}
|
||||
|
||||
@Get('folder/unique-paths')
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Retrieve unique paths',
|
||||
description: 'Retrieve a list of unique folder paths from asset original paths.',
|
||||
@@ -24,7 +24,7 @@ export class ViewController {
|
||||
}
|
||||
|
||||
@Get('folder')
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Authenticated()
|
||||
@Endpoint({
|
||||
summary: 'Retrieve assets by original path',
|
||||
description: 'Retrieve assets that are children of a specific folder.',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
Original = 'original',
|
||||
/**
|
||||
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
|
||||
* or otherwise the original image itself.
|
||||
|
||||
@@ -585,9 +585,6 @@ class SystemConfigGeneratedImageDto {
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigGeneratedFullsizeImageDto {
|
||||
@@ -603,9 +600,6 @@ class SystemConfigGeneratedFullsizeImageDto {
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
export class SystemConfigImageDto {
|
||||
|
||||
@@ -47,13 +47,6 @@ export enum AssetFileType {
|
||||
Sidecar = 'sidecar',
|
||||
}
|
||||
|
||||
export enum GeneratedImageType {
|
||||
Thumbnail = 'thumbnail',
|
||||
Preview = 'preview',
|
||||
Fullsize = 'fullsize',
|
||||
Person = 'person',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
Editor = 'editor',
|
||||
Viewer = 'viewer',
|
||||
@@ -153,8 +146,6 @@ export enum Permission {
|
||||
FaceUpdate = 'face.update',
|
||||
FaceDelete = 'face.delete',
|
||||
|
||||
FolderRead = 'folder.read',
|
||||
|
||||
JobCreate = 'job.create',
|
||||
JobRead = 'job.read',
|
||||
|
||||
|
||||
@@ -15,5 +15,3 @@ from
|
||||
"asset_edit"
|
||||
where
|
||||
"assetId" = $1
|
||||
order by
|
||||
"sequence" asc
|
||||
|
||||
@@ -585,40 +585,3 @@ where
|
||||
and "libraryId" = $2::uuid
|
||||
and "isExternal" = $3
|
||||
)
|
||||
|
||||
-- AssetRepository.getForOriginal
|
||||
select
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" = $3
|
||||
|
||||
-- AssetRepository.getForThumbnail
|
||||
select
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_file"."path" as "path"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."type" = $1
|
||||
where
|
||||
"asset"."id" = $2
|
||||
order by
|
||||
"asset_file"."isEdited" desc
|
||||
|
||||
-- AssetRepository.getForVideo
|
||||
select
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
|
||||
@@ -12,14 +12,14 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
||||
|
||||
if (edits.length > 0) {
|
||||
return trx
|
||||
.insertInto('asset_edit')
|
||||
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
|
||||
.values(edits.map((edit) => ({ assetId, ...edit })))
|
||||
.returning(['action', 'parameters'])
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
}
|
||||
@@ -31,12 +31,11 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
async getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
return this.db
|
||||
.selectFrom('asset_edit')
|
||||
.select(['action', 'parameters'])
|
||||
.where('assetId', '=', assetId)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,47 +1009,4 @@ export class AssetRepository {
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
async getForOriginal(id: string, isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select('originalFileName')
|
||||
.where('asset.id', '=', id)
|
||||
.$if(isEdited, (qb) =>
|
||||
qb
|
||||
.leftJoin('asset_file', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_file.assetId')
|
||||
.on('asset_file.isEdited', '=', true)
|
||||
.on('asset_file.type', '=', AssetFileType.FullSize),
|
||||
)
|
||||
.select('asset_file.path as editedPath'),
|
||||
)
|
||||
.select('originalPath')
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] })
|
||||
async getForThumbnail(id: string, type: AssetFileType, isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', id)
|
||||
.leftJoin('asset_file', (join) =>
|
||||
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', type),
|
||||
)
|
||||
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
||||
.orderBy('asset_file.isEdited', isEdited ? 'desc' : 'asc')
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.encodedVideoPath', 'asset.originalPath'])
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { RedisOptions } from 'ioredis';
|
||||
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||
import { join } from 'node:path';
|
||||
import { bullMQTelemetry } from 'src/telemetry-preload';
|
||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { EnvDto } from 'src/dtos/env.dto';
|
||||
@@ -263,7 +262,6 @@ const getEnv = (): EnvData => {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
telemetry: bullMQTelemetry,
|
||||
},
|
||||
queues: Object.values(QueueName).map((name) => ({ name })),
|
||||
},
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { context, Span, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
|
||||
import { Duration } from 'luxon';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { MachineLearningConfig } from 'src/config';
|
||||
import { CLIPConfig } from 'src/dtos/model-config.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { Traced } from 'src/utils/instrumentation';
|
||||
|
||||
const tracer = trace.getTracer('immich');
|
||||
|
||||
export interface BoundingBox {
|
||||
x1: number;
|
||||
@@ -166,60 +162,36 @@ export class MachineLearningRepository {
|
||||
}
|
||||
|
||||
private async predict<T>(payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
const taskType = Object.keys(config)[0] as ModelTask;
|
||||
const formData = await this.getFormData(payload, config);
|
||||
|
||||
return tracer.startActiveSpan('ml.predict', { kind: SpanKind.CLIENT }, context.active(), async (span: Span) => {
|
||||
span.setAttribute('ml.task', taskType);
|
||||
|
||||
const formData = await this.getFormData(payload, config);
|
||||
let attemptCount = 0;
|
||||
let successUrl: string | undefined;
|
||||
|
||||
const urls = [
|
||||
...this.config.urls.filter((url) => this.isHealthy(url)),
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
attemptCount++;
|
||||
try {
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setHealthy(url, true);
|
||||
successUrl = url;
|
||||
span.setAttribute('ml.url', url);
|
||||
span.setAttribute('ml.attempts', attemptCount);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
span.end();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Machine learning request to "${url}" failed with status ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
} catch (error: Error | unknown) {
|
||||
this.logger.warn(
|
||||
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
for (const url of [
|
||||
// try healthy servers first
|
||||
...this.config.urls.filter((url) => this.isHealthy(url)),
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
]) {
|
||||
try {
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setHealthy(url, true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
this.setHealthy(url, false);
|
||||
this.logger.warn(
|
||||
`Machine learning request to "${url}" failed with status ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
} catch (error: Error | unknown) {
|
||||
this.logger.warn(
|
||||
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
|
||||
span.setAttribute('ml.attempts', attemptCount);
|
||||
const errorMessage = `Machine learning request '${JSON.stringify(config)}' failed for all URLs`;
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
|
||||
span.end();
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
this.setHealthy(url, false);
|
||||
}
|
||||
|
||||
throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`);
|
||||
}
|
||||
|
||||
@Traced({ name: 'ml.detectFaces', kind: SpanKind.CLIENT })
|
||||
async detectFaces(imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('ml.modelName', modelName);
|
||||
span?.setAttribute('ml.minScore', minScore);
|
||||
|
||||
const request = {
|
||||
[ModelTask.FACIAL_RECOGNITION]: {
|
||||
[ModelType.DETECTION]: { modelName, options: { minScore } },
|
||||
@@ -234,36 +206,19 @@ export class MachineLearningRepository {
|
||||
};
|
||||
}
|
||||
|
||||
@Traced({ name: 'ml.encodeImage', kind: SpanKind.CLIENT })
|
||||
async encodeImage(imagePath: string, { modelName }: CLIPConfig) {
|
||||
trace.getActiveSpan()?.setAttribute('ml.modelName', modelName);
|
||||
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
|
||||
const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
@Traced({ name: 'ml.encodeText', kind: SpanKind.CLIENT })
|
||||
async encodeText(text: string, { language, modelName }: TextEncodingOptions) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('ml.modelName', modelName);
|
||||
if (language) {
|
||||
span?.setAttribute('ml.language', language);
|
||||
}
|
||||
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } };
|
||||
const response = await this.predict<ClipTextualResponse>({ text }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
@Traced({ name: 'ml.ocr', kind: SpanKind.CLIENT })
|
||||
async ocr(imagePath: string, { modelName, minDetectionScore, minRecognitionScore, maxResolution }: OcrOptions) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('ml.modelName', modelName);
|
||||
span?.setAttribute('ml.minDetectionScore', minDetectionScore);
|
||||
span?.setAttribute('ml.minRecognitionScore', minRecognitionScore);
|
||||
span?.setAttribute('ml.maxResolution', maxResolution);
|
||||
|
||||
const request = {
|
||||
[ModelTask.OCR]: {
|
||||
[ModelType.DETECTION]: { modelName, options: { minScore: minDetectionScore, maxResolution } },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { context, Span, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
|
||||
import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||
import { Duration } from 'luxon';
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
TranscodeCommand,
|
||||
VideoInfo,
|
||||
} from 'src/types';
|
||||
import { Traced } from 'src/utils/instrumentation';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
import { createAffineMatrix } from 'src/utils/transform';
|
||||
|
||||
@@ -28,17 +26,9 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
|
||||
new Promise((resolve, reject) =>
|
||||
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
|
||||
);
|
||||
const tracer = trace.getTracer('immich');
|
||||
sharp.concurrency(0);
|
||||
sharp.cache({ files: 0 });
|
||||
|
||||
const EXTRACTION_METHODS = [
|
||||
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'JpgFromRaw', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'PreviewJXL', format: RawExtractedFormat.Jxl },
|
||||
{ tag: 'PreviewImage', format: RawExtractedFormat.Jpeg },
|
||||
] as const;
|
||||
|
||||
type ProgressEvent = {
|
||||
frames: number;
|
||||
currentFps: number;
|
||||
@@ -59,66 +49,81 @@ export class MediaRepository {
|
||||
this.logger.setContext(MediaRepository.name);
|
||||
}
|
||||
|
||||
@Traced('media.exiftool.extract')
|
||||
/**
|
||||
*
|
||||
* @param input file path to the input image
|
||||
* @returns ExtractResult if succeeded, or null if failed
|
||||
*/
|
||||
async extract(input: string): Promise<ExtractResult | null> {
|
||||
for (const { tag, format } of EXTRACTION_METHODS) {
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer(tag, input);
|
||||
trace.getActiveSpan()?.setAttribute('exiftool.extractedTag', tag);
|
||||
return { buffer, format };
|
||||
} catch {
|
||||
this.logger.debug(`Could not extract ${tag} from image, trying next method`);
|
||||
}
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||
}
|
||||
trace.getActiveSpan()?.setAttribute('exiftool.extractedTag', 'none');
|
||||
return null;
|
||||
}
|
||||
|
||||
@Traced('media.exiftool.writeExif')
|
||||
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||
const tagsToWrite: WriteTags = {
|
||||
ExifImageWidth: tags.exifImageWidth,
|
||||
ExifImageHeight: tags.exifImageHeight,
|
||||
DateTimeOriginal: tags.dateTimeOriginal && ExifDateTime.fromMillis(tags.dateTimeOriginal.getTime()),
|
||||
ModifyDate: tags.modifyDate && ExifDateTime.fromMillis(tags.modifyDate.getTime()),
|
||||
TimeZone: tags.timeZone,
|
||||
GPSLatitude: tags.latitude,
|
||||
GPSLongitude: tags.longitude,
|
||||
ProjectionType: tags.projectionType,
|
||||
City: tags.city,
|
||||
Country: tags.country,
|
||||
Make: tags.make,
|
||||
Model: tags.model,
|
||||
LensModel: tags.lensModel,
|
||||
Fnumber: tags.fNumber?.toFixed(1),
|
||||
FocalLength: tags.focalLength?.toFixed(1),
|
||||
ISO: tags.iso,
|
||||
ExposureTime: tags.exposureTime,
|
||||
ProfileDescription: tags.profileDescription,
|
||||
ColorSpace: tags.colorspace,
|
||||
Rating: tags.rating,
|
||||
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||
return { buffer, format: RawExtractedFormat.Jxl };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||
try {
|
||||
const tagsToWrite: WriteTags = {
|
||||
ExifImageWidth: tags.exifImageWidth,
|
||||
ExifImageHeight: tags.exifImageHeight,
|
||||
DateTimeOriginal: tags.dateTimeOriginal && ExifDateTime.fromMillis(tags.dateTimeOriginal.getTime()),
|
||||
ModifyDate: tags.modifyDate && ExifDateTime.fromMillis(tags.modifyDate.getTime()),
|
||||
TimeZone: tags.timeZone,
|
||||
GPSLatitude: tags.latitude,
|
||||
GPSLongitude: tags.longitude,
|
||||
ProjectionType: tags.projectionType,
|
||||
City: tags.city,
|
||||
Country: tags.country,
|
||||
Make: tags.make,
|
||||
Model: tags.model,
|
||||
LensModel: tags.lensModel,
|
||||
Fnumber: tags.fNumber?.toFixed(1),
|
||||
FocalLength: tags.focalLength?.toFixed(1),
|
||||
ISO: tags.iso,
|
||||
ExposureTime: tags.exposureTime,
|
||||
ProfileDescription: tags.profileDescription,
|
||||
ColorSpace: tags.colorspace,
|
||||
Rating: tags.rating,
|
||||
// specially convert Orientation to numeric Orientation# for exiftool
|
||||
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
|
||||
};
|
||||
|
||||
await exiftool.write(output, tagsToWrite, {
|
||||
ignoreMinorErrors: true,
|
||||
writeArgs: ['-overwrite_original'],
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
||||
span?.recordException(error as Error);
|
||||
this.logger.warn(`Could not write exif data to image: ${error}`);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Traced('media.exiftool.copyTagGroup')
|
||||
async copyTagGroup(tagGroup: string, source: string, target: string): Promise<boolean> {
|
||||
trace.getActiveSpan()?.setAttribute('exiftool.tagGroup', tagGroup);
|
||||
|
||||
try {
|
||||
await exiftool.write(
|
||||
target,
|
||||
@@ -129,28 +134,15 @@ export class MediaRepository {
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
||||
span?.recordException(error as Error);
|
||||
this.logger.warn(`Could not copy tag data to image: ${error}`);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Traced('media.decodeImage')
|
||||
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('media.colorspace', options.colorspace);
|
||||
if (options.size !== undefined) {
|
||||
span?.setAttribute('media.size', options.size);
|
||||
}
|
||||
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
const result = await pipeline.raw().toBuffer({ resolveWithObject: true });
|
||||
span?.setAttribute('media.output.width', result.info.width);
|
||||
span?.setAttribute('media.output.height', result.info.height);
|
||||
return result;
|
||||
return pipeline.raw().toBuffer({ resolveWithObject: true });
|
||||
}
|
||||
|
||||
private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise<sharp.Sharp> {
|
||||
@@ -178,23 +170,12 @@ export class MediaRepository {
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
@Traced('media.generateThumbnail')
|
||||
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('media.imageType', options.imageType ?? 'unknown');
|
||||
span?.setAttribute('media.format', options.format);
|
||||
span?.setAttribute('media.quality', options.quality);
|
||||
span?.setAttribute('media.size', options.size ?? 0);
|
||||
span?.setAttribute('media.colorspace', options.colorspace);
|
||||
if (options.progressive !== undefined) {
|
||||
span?.setAttribute('media.progressive', options.progressive);
|
||||
}
|
||||
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
const decoded = pipeline.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
});
|
||||
|
||||
await decoded.toFile(output);
|
||||
@@ -233,11 +214,7 @@ export class MediaRepository {
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
@Traced('media.generateThumbhash')
|
||||
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('media.colorspace', options.colorspace);
|
||||
|
||||
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([
|
||||
import('thumbhash'),
|
||||
this.getImageDecodingPipeline(input, {
|
||||
@@ -251,23 +228,12 @@ export class MediaRepository {
|
||||
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha();
|
||||
|
||||
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
||||
span?.setAttribute('media.thumbhash.width', info.width);
|
||||
span?.setAttribute('media.thumbhash.height', info.height);
|
||||
|
||||
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
|
||||
}
|
||||
|
||||
@Traced('ffmpeg.probe')
|
||||
async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('ffmpeg.countFrames', options?.countFrames ?? false);
|
||||
|
||||
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
|
||||
|
||||
span?.setAttribute('ffmpeg.duration', this.parseFloat(results.format.duration));
|
||||
span?.setAttribute('ffmpeg.videoStreams', results.streams.filter((s) => s.codec_type === 'video').length);
|
||||
span?.setAttribute('ffmpeg.audioStreams', results.streams.filter((s) => s.codec_type === 'audio').length);
|
||||
|
||||
return {
|
||||
format: {
|
||||
formatName: results.format.format_name,
|
||||
@@ -305,39 +271,17 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||
const span = tracer.startSpan('ffmpeg.transcode', { kind: SpanKind.INTERNAL }, context.active());
|
||||
span.setAttribute('ffmpeg.twoPass', options.twoPass ?? false);
|
||||
span.setAttribute('ffmpeg.frameCount', options.progress.frameCount);
|
||||
|
||||
const endSpan = (error?: Error) => {
|
||||
if (error) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
||||
span.recordException(error);
|
||||
} else {
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
}
|
||||
span.end();
|
||||
};
|
||||
|
||||
if (!options.twoPass) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.configureFfmpegCall(input, output, options)
|
||||
.on('error', (error) => {
|
||||
endSpan(error);
|
||||
reject(error);
|
||||
})
|
||||
.on('end', () => {
|
||||
endSpan();
|
||||
resolve();
|
||||
})
|
||||
.on('error', reject)
|
||||
.on('end', () => resolve())
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof output !== 'string') {
|
||||
const error = new TypeError('Two-pass transcoding does not support writing to a stream');
|
||||
endSpan(error);
|
||||
throw error;
|
||||
throw new TypeError('Two-pass transcoding does not support writing to a stream');
|
||||
}
|
||||
|
||||
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||
@@ -348,37 +292,24 @@ export class MediaRepository {
|
||||
.addOptions('-pass', '1')
|
||||
.addOptions('-passlogfile', output)
|
||||
.addOptions('-f null')
|
||||
.on('error', (error) => {
|
||||
endSpan(error);
|
||||
reject(error);
|
||||
})
|
||||
.on('error', reject)
|
||||
.on('end', () => {
|
||||
// second pass
|
||||
this.configureFfmpegCall(input, output, options)
|
||||
.addOptions('-pass', '2')
|
||||
.addOptions('-passlogfile', output)
|
||||
.on('error', (error) => {
|
||||
endSpan(error);
|
||||
reject(error);
|
||||
})
|
||||
.on('error', reject)
|
||||
.on('end', () => handlePromiseError(fs.unlink(`${output}-0.log`), this.logger))
|
||||
.on('end', () => handlePromiseError(fs.rm(`${output}-0.log.mbtree`, { force: true }), this.logger))
|
||||
.on('end', () => {
|
||||
endSpan();
|
||||
resolve();
|
||||
})
|
||||
.on('end', () => resolve())
|
||||
.run();
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
@Traced('media.getImageDimensions')
|
||||
async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
|
||||
const { width = 0, height = 0 } = await sharp(input).metadata();
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('media.width', width);
|
||||
span?.setAttribute('media.height', height);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SpanStatusCode, trace } from '@opentelemetry/api';
|
||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||
import geotz from 'geo-tz';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { Traced } from 'src/utils/instrumentation';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
interface ExifDuration {
|
||||
@@ -106,34 +104,22 @@ export class MetadataRepository {
|
||||
await this.exiftool.end();
|
||||
}
|
||||
|
||||
@Traced('metadata.exiftool.readTags')
|
||||
async readTags(path: string): Promise<ImmichTags> {
|
||||
try {
|
||||
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
|
||||
return (await this.exiftool.read(path, args)) as ImmichTags;
|
||||
} catch (error: unknown) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
||||
span?.recordException(error as Error);
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}`);
|
||||
readTags(path: string): Promise<ImmichTags> {
|
||||
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
|
||||
return this.exiftool.read(path, args).catch((error) => {
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||
return {};
|
||||
}
|
||||
}) as Promise<ImmichTags>;
|
||||
}
|
||||
|
||||
@Traced('metadata.exiftool.extractBinaryTag')
|
||||
async extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||
trace.getActiveSpan()?.setAttribute('exiftool.tagName', tagName);
|
||||
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||
return this.exiftool.extractBinaryTagToBuffer(tagName, path);
|
||||
}
|
||||
|
||||
@Traced('metadata.exiftool.writeTags')
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
try {
|
||||
await this.exiftool.write(path, tags);
|
||||
} catch (error: unknown) {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
||||
span?.recordException(error as Error);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
@@ -10,7 +9,6 @@ import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
import { createGunzip, createGzip } from 'node:zlib';
|
||||
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { Traced } from 'src/utils/instrumentation';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
export interface WatchEvents {
|
||||
@@ -108,18 +106,9 @@ export class StorageRepository {
|
||||
return createReadStream(filepath);
|
||||
}
|
||||
|
||||
@Traced('storage.createReadStream')
|
||||
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
|
||||
const { size } = await fs.stat(filepath);
|
||||
await fs.access(filepath, constants.R_OK);
|
||||
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('file.path', filepath);
|
||||
span?.setAttribute('file.size', size);
|
||||
if (mimeType) {
|
||||
span?.setAttribute('file.content_type', mimeType);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(filepath),
|
||||
length: size,
|
||||
@@ -127,15 +116,10 @@ export class StorageRepository {
|
||||
};
|
||||
}
|
||||
|
||||
@Traced('storage.readFile')
|
||||
async readFile(filepath: string, options?: ReadOptionsWithBuffer<Buffer>): Promise<Buffer> {
|
||||
const span = trace.getActiveSpan();
|
||||
span?.setAttribute('file.path', filepath);
|
||||
|
||||
const file = await fs.open(filepath);
|
||||
try {
|
||||
const { buffer, bytesRead } = await file.read(options);
|
||||
span?.setAttribute('file.bytes_read', bytesRead);
|
||||
const { buffer } = await file.read(options);
|
||||
return buffer as Buffer;
|
||||
} finally {
|
||||
await file.close();
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { MetricOptions } from '@opentelemetry/api';
|
||||
import { contextBase } from '@opentelemetry/sdk-node';
|
||||
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
|
||||
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
|
||||
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { AggregationType } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK, contextBase } from '@opentelemetry/sdk-node';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { snakeCase, startCase } from 'lodash';
|
||||
import { MetricService } from 'nestjs-otel';
|
||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichTelemetry, MetadataKey } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -41,16 +51,49 @@ export class MetricGroupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenTelemetry SDK is now initialized in telemetry-preload.ts
|
||||
// which runs before any modules are imported. This ensures the
|
||||
// global meter provider is available for nestjs-otel.
|
||||
const aggregationBoundaries = [
|
||||
0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000,
|
||||
];
|
||||
|
||||
export const bootstrapTelemetry = (_port: number) => {
|
||||
// No-op: SDK is initialized in telemetry-preload.ts
|
||||
let instance: NodeSDK | undefined;
|
||||
|
||||
export const bootstrapTelemetry = (port: number) => {
|
||||
if (instance) {
|
||||
throw new Error('OpenTelemetry SDK already started');
|
||||
}
|
||||
instance = new NodeSDK({
|
||||
resource: resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: `immich`,
|
||||
[ATTR_SERVICE_VERSION]: serverVersion.toString(),
|
||||
}),
|
||||
metricReader: new PrometheusExporter({ port }),
|
||||
contextManager: new AsyncLocalStorageContextManager(),
|
||||
instrumentations: [
|
||||
new HttpInstrumentation(),
|
||||
new IORedisInstrumentation(),
|
||||
new NestInstrumentation(),
|
||||
new PgInstrumentation(),
|
||||
],
|
||||
views: [
|
||||
{
|
||||
instrumentName: '*',
|
||||
instrumentUnit: 'ms',
|
||||
aggregation: {
|
||||
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
|
||||
options: { boundaries: aggregationBoundaries },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
instance.start();
|
||||
};
|
||||
|
||||
export const teardownTelemetry = async () => {
|
||||
// No-op: SDK shutdown is handled by process exit
|
||||
if (instance) {
|
||||
await instance.shutdown();
|
||||
instance = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`DELETE FROM "asset_edit";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Unique,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table('asset_edit')
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
referencingOldTableAs: 'deleted_edit',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
@Unique({ columns: ['assetId', 'sequence'] })
|
||||
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
@@ -33,7 +31,4 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
parameters!: AssetEditActionParameter[T];
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
sequence!: number;
|
||||
}
|
||||
|
||||
@@ -500,9 +500,17 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true, edits: true });
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -528,10 +536,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -557,10 +562,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -586,7 +588,7 @@ describe(AssetMediaService.name, () => {
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
|
||||
mocks.asset.getById.mockResolvedValue(editedAsset);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -597,6 +599,29 @@ describe(AssetMediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download original file when no edits exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a not found when edits exist but no edited file available', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.withCropEdit);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
@@ -608,9 +633,54 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image, files: [] });
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the requested preview file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
id: '42',
|
||||
path: '/path/to/preview',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should fall back to preview if the requested thumbnail file does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/path/to/preview.jpg' });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [
|
||||
{
|
||||
id: '42',
|
||||
path: '/path/to/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
@@ -626,7 +696,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should get preview file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/thumbs/path.jpg' });
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).resolves.toEqual(
|
||||
@@ -641,7 +711,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should get thumbnail file', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/webp/path.ext' });
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image });
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
@@ -652,65 +722,9 @@ describe(AssetMediaService.name, () => {
|
||||
fileName: 'asset-id_thumbnail.ext',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should get original thumbnail by default', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should get edited thumbnail when edited=true', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
it('should get original thumbnail when edited=false', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: false }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
// TODO: Edited asset tests
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
@@ -722,15 +736,22 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the video asset could not be found', async () => {
|
||||
it('should throw an error if the asset does not exist', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not a video', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.hasEncodedVideo);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -743,7 +764,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should fall back to the original path', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(assetStub.video);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
CacheControl,
|
||||
JobName,
|
||||
@@ -35,7 +36,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -196,17 +197,27 @@ export class AssetMediaService extends BaseService {
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||
|
||||
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
|
||||
id,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
const asset = await this.findOrFail(id);
|
||||
|
||||
const path = editedPath ?? originalPath!;
|
||||
if (asset.edits!.length > 0 && (dto.edited ?? false)) {
|
||||
const { editedFullsizeFile } = getAssetFiles(asset.files ?? []);
|
||||
|
||||
if (!editedFullsizeFile) {
|
||||
throw new NotFoundException('Edited asset media not found');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: editedFullsizeFile.path,
|
||||
fileName: getFileNameWithoutExtension(asset.originalFileName) + getFilenameExtension(editedFullsizeFile.path),
|
||||
contentType: mimeTypes.lookup(editedFullsizeFile.path),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path,
|
||||
fileName: getFileNameWithoutExtension(originalFileName) + getFilenameExtension(path),
|
||||
contentType: mimeTypes.lookup(path),
|
||||
path: asset.originalPath,
|
||||
fileName: asset.originalFileName,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
@@ -218,38 +229,45 @@ export class AssetMediaService extends BaseService {
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
if (dto.size === AssetMediaSize.Original) {
|
||||
throw new BadRequestException('May not request original file');
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
const files = getAssetFiles(asset.files ?? []);
|
||||
|
||||
const requestingEdited = (dto.edited ?? false) && asset.edits!.length > 0;
|
||||
const { fullsizeFile, previewFile, thumbnailFile } = {
|
||||
fullsizeFile: requestingEdited ? files.editedFullsizeFile : files.fullsizeFile,
|
||||
previewFile: requestingEdited ? files.editedPreviewFile : files.previewFile,
|
||||
thumbnailFile: requestingEdited ? files.editedThumbnailFile : files.thumbnailFile,
|
||||
};
|
||||
|
||||
let filepath = previewFile?.path;
|
||||
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
|
||||
filepath = thumbnailFile.path;
|
||||
} else if (size === AssetMediaSize.FULLSIZE) {
|
||||
if (mimeTypes.isWebSupportedImage(asset.originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
if (!fullsizeFile) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
filepath = fullsizeFile.path;
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
size,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
|
||||
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
|
||||
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
if (!filepath) {
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`;
|
||||
let fileName = getFileNameWithoutExtension(asset.originalFileName);
|
||||
fileName += `_${size}`;
|
||||
fileName += getFilenameExtension(filepath);
|
||||
|
||||
return new ImmichFileResponse({
|
||||
fileName,
|
||||
path,
|
||||
contentType: mimeTypes.lookup(path),
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
@@ -257,10 +275,10 @@ export class AssetMediaService extends BaseService {
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForVideo(id);
|
||||
const asset = await this.findOrFail(id);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found or asset is not a video');
|
||||
if (asset.type !== AssetType.Video) {
|
||||
throw new BadRequestException('Asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
@@ -469,4 +487,13 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const asset = await this.assetRepository.getById(id, { files: true, edits: true });
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ExifOrientation,
|
||||
GeneratedImageType,
|
||||
ImageFormat,
|
||||
JobName,
|
||||
JobStatus,
|
||||
@@ -353,11 +352,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -368,11 +365,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Thumbnail,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -580,11 +575,9 @@ describe(MediaService.name, () => {
|
||||
format,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
previewPath,
|
||||
);
|
||||
@@ -595,11 +588,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Thumbnail,
|
||||
},
|
||||
thumbnailPath,
|
||||
);
|
||||
@@ -631,11 +622,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
previewPath,
|
||||
);
|
||||
@@ -646,68 +635,14 @@ describe(MediaService.name, () => {
|
||||
format,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Thumbnail,
|
||||
},
|
||||
thumbnailPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for preview when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: false } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('preview.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Webp,
|
||||
progressive: false,
|
||||
}),
|
||||
expect.stringContaining('thumbnail.webp'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: false,
|
||||
}),
|
||||
expect.stringContaining('preview.jpeg'),
|
||||
);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('thumbnail.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
@@ -841,11 +776,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -874,11 +807,9 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -889,11 +820,9 @@ describe(MediaService.name, () => {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -920,11 +849,9 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -934,12 +861,10 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
size: 1440,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Preview,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -967,11 +892,9 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1025,11 +948,9 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.Srgb,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1066,36 +987,13 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 90,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
raw: rawInfo,
|
||||
edits: [],
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for fullsize when enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
format: ImageFormat.Jpeg,
|
||||
progressive: true,
|
||||
}),
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
@@ -1300,7 +1198,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1315,7 +1212,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1346,7 +1242,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1361,7 +1256,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1390,7 +1284,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1405,7 +1298,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1434,7 +1326,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1449,7 +1340,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1478,7 +1368,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1493,7 +1382,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1522,7 +1410,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1537,7 +1424,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
@@ -1571,7 +1457,6 @@ describe(MediaService.name, () => {
|
||||
colorspace: Colorspace.P3,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
@@ -1586,7 +1471,6 @@ describe(MediaService.name, () => {
|
||||
raw: info,
|
||||
processInvalidImages: false,
|
||||
size: 250,
|
||||
imageType: GeneratedImageType.Person,
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AssetVisibility,
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
GeneratedImageType,
|
||||
ImageFormat,
|
||||
JobName,
|
||||
JobStatus,
|
||||
@@ -330,12 +329,12 @@ export class MediaService extends BaseService {
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.thumbnail, ...thumbnailOptions, imageType: GeneratedImageType.Thumbnail, edits: useEdits ? asset.edits : [] },
|
||||
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
thumbnailPath,
|
||||
),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.preview, ...thumbnailOptions, imageType: GeneratedImageType.Preview, edits: useEdits ? asset.edits : [] },
|
||||
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
previewPath,
|
||||
),
|
||||
];
|
||||
@@ -352,8 +351,6 @@ export class MediaService extends BaseService {
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
progressive: image.fullsize.progressive,
|
||||
imageType: GeneratedImageType.Fullsize,
|
||||
...thumbnailOptions,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
@@ -437,10 +434,8 @@ export class MediaService extends BaseService {
|
||||
format: ImageFormat.Jpeg,
|
||||
raw: info,
|
||||
quality: image.thumbnail.quality,
|
||||
progressive: false,
|
||||
processInvalidImages: false,
|
||||
size: FACE_THUMBNAIL_SIZE,
|
||||
imageType: GeneratedImageType.Person,
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
|
||||
@@ -387,7 +387,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -398,7 +398,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -419,7 +419,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -430,7 +430,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -441,10 +441,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -456,7 +453,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -476,10 +473,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -499,10 +493,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -524,10 +515,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -539,7 +527,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) });
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@@ -554,10 +542,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...factory.asset(),
|
||||
exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }),
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
|
||||
@@ -167,15 +167,13 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
size: 250,
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
size: 1440,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80, progressive: false },
|
||||
fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 },
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user