Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen
5ccc05feeb fix: foreground cloud sync 2026-01-22 20:37:44 +05:30
184 changed files with 1545 additions and 5222 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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]

View File

@@ -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',

View File

@@ -1,7 +1,10 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
@@ -23,44 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect(original).toBeInViewport();
await expect(original).toHaveAttribute('src', /original/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect(original).toHaveAttribute('src', /fullsize/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
});
});

View File

@@ -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.",
@@ -1913,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",
@@ -2118,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",

View File

@@ -30,8 +30,3 @@ class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
// Map Events
class MapMarkerReloadEvent extends Event {
const MapMarkerReloadEvent();
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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}) {

View File

@@ -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();
}

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -75,7 +75,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();

View File

@@ -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,
),
),

View File

@@ -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}",
),
);

View File

@@ -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,
),
),
),
],
),
);

View File

@@ -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,

View File

@@ -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,
),
);

View File

@@ -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;

View File

@@ -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

View File

@@ -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),
),
),
),
],
);
}
}

View File

@@ -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;
}),

View File

@@ -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.

View File

@@ -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,

View File

@@ -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),
],
),
),
),
);
}
}

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;
});

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,
);

View File

@@ -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"}),
),
],
),
),
],
),
),
],
);
}
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
),
),

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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:
///

View File

@@ -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;

View File

@@ -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;

View File

@@ -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')!,
);
}

View File

@@ -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')!,
);

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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"
}

213
pnpm-lock.yaml generated
View File

@@ -36,7 +36,7 @@ importers:
version: 1.20.1
lodash-es:
specifier: ^4.17.21
version: 4.17.23
version: 4.17.22
micromatch:
specifier: ^4.0.8
version: 4.0.8
@@ -489,7 +489,7 @@ importers:
version: 3.0.0(kysely@0.28.2)(postgres@3.4.8)
lodash:
specifier: ^4.17.21
version: 4.17.23
version: 4.17.21
luxon:
specifier: ^3.4.2
version: 3.7.2
@@ -742,7 +742,7 @@ importers:
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.59.0
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -775,7 +775,7 @@ importers:
version: 0.41.4
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.48.0)
version: 0.3.8(svelte@5.46.4)
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -802,7 +802,7 @@ importers:
version: 4.1.0
lodash-es:
specifier: ^4.17.21
version: 4.17.23
version: 4.17.22
luxon:
specifier: ^3.4.4
version: 3.7.2
@@ -826,16 +826,16 @@ importers:
version: 5.2.2
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.48.0)
version: 4.0.1(svelte@5.46.4)
svelte-jsoneditor:
specifier: ^3.10.0
version: 3.11.0(svelte@5.48.0)
version: 3.11.0(svelte@5.46.4)
svelte-maplibre:
specifier: ^1.2.5
version: 1.2.5(svelte@5.48.0)
version: 1.2.5(svelte@5.46.4)
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.48.0)
version: 0.12.0(svelte@5.46.4)
tabbable:
specifier: ^6.2.0
version: 6.4.0
@@ -860,16 +860,16 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img':
specifier: ^0.9.0
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit':
specifier: ^2.27.1
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 6.2.4
version: 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -878,7 +878,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -917,7 +917,7 @@ importers:
version: 6.0.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0)
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.2(jiti@2.6.1))
@@ -938,19 +938,19 @@ importers:
version: 4.2.0(prettier@3.8.0)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.4.1(prettier@3.8.0)(svelte@5.48.0)
version: 3.4.1(prettier@3.8.0)(svelte@5.46.4)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.5(rollup@4.55.1)
svelte:
specifier: 5.48.0
version: 5.48.0
specifier: 5.46.4
version: 5.46.4
svelte-check:
specifier: ^4.1.5
version: 4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3)
version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.4.1(svelte@5.48.0)
version: 1.4.1(svelte@5.46.4)
tailwindcss:
specifier: ^4.1.7
version: 4.1.18
@@ -8916,8 +8916,8 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -8964,9 +8964,6 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
@@ -11608,8 +11605,8 @@ packages:
peerDependencies:
svelte: ^5.30.2
svelte@5.48.0:
resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==}
svelte@5.46.4:
resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
engines: {node: '>=18'}
svg-parser@2.0.4:
@@ -14545,7 +14542,7 @@ snapshots:
html-tags: 3.3.1
html-webpack-plugin: 5.6.5(webpack@5.104.1)
leven: 3.1.0
lodash: 4.17.23
lodash: 4.17.21
open: 8.4.2
p-map: 4.0.0
prompts: 2.4.2
@@ -14662,7 +14659,7 @@ snapshots:
cheerio: 1.0.0-rc.12
feed: 4.2.2
fs-extra: 11.3.2
lodash: 4.17.23
lodash: 4.17.21
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -14704,7 +14701,7 @@ snapshots:
combine-promises: 1.2.0
fs-extra: 11.3.2
js-yaml: 4.1.1
lodash: 4.17.23
lodash: 4.17.21
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -15017,7 +15014,7 @@ snapshots:
'@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1)
clsx: 2.1.1
infima: 0.2.0-alpha.45
lodash: 4.17.23
lodash: 4.17.21
nprogress: 0.2.0
postcss: 8.5.6
prism-react-renderer: 2.4.1(react@18.3.1)
@@ -15115,7 +15112,7 @@ snapshots:
clsx: 2.1.1
eta: 2.2.0
fs-extra: 11.3.2
lodash: 4.17.23
lodash: 4.17.21
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
@@ -15190,7 +15187,7 @@ snapshots:
fs-extra: 11.3.2
joi: 17.13.3
js-yaml: 4.1.1
lodash: 4.17.23
lodash: 4.17.21
tslib: 2.8.1
transitivePeerDependencies:
- '@swc/core'
@@ -15215,7 +15212,7 @@ snapshots:
gray-matter: 4.0.3
jiti: 1.21.7
js-yaml: 4.1.1
lodash: 4.17.23
lodash: 4.17.21
micromatch: 4.0.8
p-queue: 6.6.2
prompts: 2.4.2
@@ -15600,7 +15597,7 @@ snapshots:
dependencies:
'@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
lodash: 4.17.23
lodash: 4.17.21
'@grpc/grpc-js@1.14.3':
dependencies:
@@ -15744,19 +15741,19 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {}
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.48.0)':
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.46.4)':
dependencies:
svelte: 5.48.0
svelte: 5.46.4
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)':
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.48.0)
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.4)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
luxon: 3.7.2
simple-icons: 16.4.0
svelte: 5.48.0
svelte: 5.46.4
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18)
@@ -17483,17 +17480,17 @@ snapshots:
dependencies:
acorn: 8.15.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.48.0
svelte-parse-markup: 0.1.5(svelte@5.48.0)
svelte: 5.46.4
svelte-parse-markup: 0.1.5(svelte@5.46.4)
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-imagetools: 9.0.2(rollup@4.55.1)
zimmerframe: 1.1.4
@@ -17501,11 +17498,11 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/cookie': 0.6.0
acorn: 8.15.0
cookie: 0.6.0
@@ -17517,28 +17514,28 @@ snapshots:
sade: 1.8.1
set-cookie-parser: 2.7.2
sirv: 3.0.2
svelte: 5.48.0
svelte: 5.46.4
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@opentelemetry/api': 1.9.0
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
debug: 4.4.3
svelte: 5.48.0
svelte: 5.46.4
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.48.0
svelte: 5.46.4
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
@@ -17786,15 +17783,15 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/svelte-core@1.0.0(svelte@5.48.0)':
'@testing-library/svelte-core@1.0.0(svelte@5.46.4)':
dependencies:
svelte: 5.48.0
svelte: 5.46.4
'@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@testing-library/svelte@5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.48.0)
svelte: 5.48.0
'@testing-library/svelte-core': 1.0.0(svelte@5.46.4)
svelte: 5.46.4
optionalDependencies:
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -18676,10 +18673,10 @@ snapshots:
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
'@zoom-image/svelte@0.3.8(svelte@5.46.4)':
dependencies:
'@zoom-image/core': 0.41.4
svelte: 5.48.0
svelte: 5.46.4
abab@2.0.6:
optional: true
@@ -18845,7 +18842,7 @@ snapshots:
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.23
lodash: 4.17.21
normalize-path: 3.0.0
readable-stream: 4.7.0
@@ -19040,15 +19037,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
svelte: 5.48.0
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
svelte: 5.46.4
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
tabbable: 6.4.0
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -19335,7 +19332,7 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
lodash-es: 4.17.23
lodash-es: 4.17.22
chevrotain@11.0.3:
dependencies:
@@ -20030,7 +20027,7 @@ snapshots:
dagre-d3-es@7.0.13:
dependencies:
d3: 7.9.0
lodash-es: 4.17.23
lodash-es: 4.17.22
data-urls@3.0.2:
dependencies:
@@ -20578,7 +20575,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0):
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
@@ -20590,9 +20587,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.6)
postcss-safe-parser: 7.0.1(postcss@8.5.6)
semver: 7.7.3
svelte-eslint-parser: 1.4.1(svelte@5.48.0)
svelte-eslint-parser: 1.4.1(svelte@5.46.4)
optionalDependencies:
svelte: 5.48.0
svelte: 5.46.4
transitivePeerDependencies:
- ts-node
@@ -21578,7 +21575,7 @@ snapshots:
dependencies:
'@types/html-minifier-terser': 6.1.0
html-minifier-terser: 6.1.0
lodash: 4.17.23
lodash: 4.17.21
pretty-error: 4.0.0
tapable: 2.3.0
optionalDependencies:
@@ -21772,7 +21769,7 @@ snapshots:
cli-cursor: 3.1.0
cli-width: 3.0.0
figures: 3.2.0
lodash: 4.17.23
lodash: 4.17.21
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
@@ -22379,7 +22376,7 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.17.23: {}
lodash-es@4.17.22: {}
lodash.camelcase@4.3.0: {}
@@ -22411,8 +22408,6 @@ snapshots:
lodash@4.17.21: {}
lodash@4.17.23: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
@@ -22815,7 +22810,7 @@ snapshots:
dompurify: 3.3.1
katex: 0.16.27
khroma: 2.1.0
lodash-es: 4.17.23
lodash-es: 4.17.22
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
@@ -23388,7 +23383,7 @@ snapshots:
node-emoji@1.11.0:
dependencies:
lodash: 4.17.23
lodash: 4.17.21
node-emoji@2.2.0:
dependencies:
@@ -24380,16 +24375,16 @@ snapshots:
dependencies:
prettier: 3.8.0
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.48.0):
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.46.4):
dependencies:
prettier: 3.8.0
svelte: 5.48.0
svelte: 5.46.4
prettier@3.8.0: {}
pretty-error@4.0.0:
dependencies:
lodash: 4.17.23
lodash: 4.17.21
renderkid: 3.0.0
pretty-format@27.5.1:
@@ -24862,7 +24857,7 @@ snapshots:
css-select: 4.3.0
dom-converter: 0.2.0
htmlparser2: 6.1.0
lodash: 4.17.23
lodash: 4.17.21
strip-ansi: 6.0.1
repeat-string@1.6.1: {}
@@ -25010,14 +25005,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.48.0
svelte: 5.46.4
optionalDependencies:
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
rw@1.3.3: {}
@@ -25647,23 +25642,23 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-awesome@3.3.5(svelte@5.48.0):
svelte-awesome@3.3.5(svelte@5.46.4):
dependencies:
svelte: 5.48.0
svelte: 5.46.4
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3):
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.48.0
svelte: 5.46.4
typescript: 5.9.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.4.1(svelte@5.48.0):
svelte-eslint-parser@1.4.1(svelte@5.46.4):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -25672,7 +25667,7 @@ snapshots:
postcss-scss: 4.0.9(postcss@8.5.6)
postcss-selector-parser: 7.1.1
optionalDependencies:
svelte: 5.48.0
svelte: 5.46.4
svelte-floating-ui@1.5.8:
dependencies:
@@ -25685,7 +25680,7 @@ snapshots:
dependencies:
highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.48.0):
svelte-i18n@4.0.1(svelte@5.46.4):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@@ -25693,10 +25688,10 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.48.0
svelte: 5.46.4
tiny-glob: 0.2.9
svelte-jsoneditor@3.11.0(svelte@5.48.0):
svelte-jsoneditor@3.11.0(svelte@5.46.4):
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1
@@ -25719,46 +25714,46 @@ snapshots:
json-source-map: 0.6.1
jsonpath-plus: 10.3.0
jsonrepair: 3.13.1
lodash-es: 4.17.23
lodash-es: 4.17.22
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1
svelte: 5.48.0
svelte-awesome: 3.3.5(svelte@5.48.0)
svelte: 5.46.4
svelte-awesome: 3.3.5(svelte@5.46.4)
svelte-select: 5.8.3
vanilla-picker: 2.12.3
svelte-maplibre@1.2.5(svelte@5.48.0):
svelte-maplibre@1.2.5(svelte@5.46.4):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
just-compare: 2.3.0
maplibre-gl: 5.16.0
pmtiles: 3.2.1
svelte: 5.48.0
svelte: 5.46.4
svelte-parse-markup@0.1.5(svelte@5.48.0):
svelte-parse-markup@0.1.5(svelte@5.46.4):
dependencies:
svelte: 5.48.0
svelte: 5.46.4
svelte-persisted-store@0.12.0(svelte@5.48.0):
svelte-persisted-store@0.12.0(svelte@5.46.4):
dependencies:
svelte: 5.48.0
svelte: 5.46.4
svelte-select@5.8.3:
dependencies:
svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
style-to-object: 1.0.14
svelte: 5.48.0
svelte: 5.46.4
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.48.0:
svelte@5.46.4:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5

View File

@@ -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: {

View File

@@ -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',

View File

@@ -66,7 +66,7 @@ export class AssetController {
}
@Post('jobs')
@Authenticated({ permission: Permission.JobCreate })
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Run an asset job',

View File

@@ -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']));
});
});
});
});

View File

@@ -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.',

View File

@@ -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.

View File

@@ -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 {

View File

@@ -146,8 +146,6 @@ export enum Permission {
FaceUpdate = 'face.update',
FaceDelete = 'face.delete',
FolderRead = 'folder.read',
JobCreate = 'job.create',
JobRead = 'job.read',

View File

@@ -15,5 +15,3 @@ from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

View File

@@ -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

View File

@@ -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[]>;
}
}

View File

@@ -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();
}
}

View File

@@ -176,7 +176,6 @@ export class MediaRepository {
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);

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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;
}
}

View File

@@ -352,7 +352,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -366,7 +365,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -577,7 +575,6 @@ describe(MediaService.name, () => {
format,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -591,7 +588,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -626,7 +622,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -640,7 +635,6 @@ describe(MediaService.name, () => {
format,
size: 250,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -649,58 +643,6 @@ describe(MediaService.name, () => {
);
});
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);
@@ -834,7 +776,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -866,7 +807,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Webp,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -880,7 +820,6 @@ describe(MediaService.name, () => {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -910,7 +849,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -923,7 +861,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
size: 1440,
processInvalidImages: false,
raw: rawInfo,
@@ -955,7 +892,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -1012,7 +948,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.Srgb,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -1052,7 +987,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Webp,
quality: 90,
progressive: false,
processInvalidImages: false,
raw: rawInfo,
edits: [],
@@ -1060,27 +994,6 @@ describe(MediaService.name, () => {
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', () => {
@@ -1285,7 +1198,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1330,7 +1242,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1373,7 +1284,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1416,7 +1326,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1459,7 +1368,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1502,7 +1410,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',
@@ -1550,7 +1457,6 @@ describe(MediaService.name, () => {
colorspace: Colorspace.P3,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
edits: [
{
action: 'crop',

View File

@@ -351,7 +351,6 @@ export class MediaService extends BaseService {
const fullsizeOptions = {
format: image.fullsize.format,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
...thumbnailOptions,
};
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
@@ -435,7 +434,6 @@ export class MediaService extends BaseService {
format: ImageFormat.Jpeg,
raw: info,
quality: image.thumbnail.quality,
progressive: false,
processInvalidImages: false,
size: FACE_THUMBNAIL_SIZE,
edits: [

View File

@@ -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);

View File

@@ -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,
},

View File

@@ -4,7 +4,6 @@ import { JobStatus } from 'src/enum';
import { TagService } from 'src/services/tag.service';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(TagService.name, () => {
@@ -192,10 +191,7 @@ describe(TagService.name, () => {
it('should upsert records', async () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })],
});
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any);
mocks.tag.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
@@ -246,10 +242,7 @@ describe(TagService.name, () => {
mocks.tag.get.mockResolvedValue(tagStub.tag);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1']));
mocks.tag.addAssetIds.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
tags: [factory.tag({ value: 'tag-1' })],
});
mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect(

View File

@@ -23,48 +23,41 @@ import {
VideoCodec,
} from 'src/enum';
export type DeepPartial<T> =
T extends Record<string, unknown>
? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends Array<infer R>
? DeepPartial<R>[]
: T;
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export type FullsizeImageOptions = {
export interface FullsizeImageOptions {
format: ImageFormat;
quality: number;
enabled: boolean;
progressive?: boolean;
};
}
export type ImageOptions = {
export interface ImageOptions {
format: ImageFormat;
quality: number;
size: number;
progressive?: boolean;
};
}
export type RawImageInfo = {
export interface RawImageInfo {
width: number;
height: number;
channels: 1 | 2 | 3 | 4;
};
}
type DecodeImageOptions = {
interface DecodeImageOptions {
colorspace: string;
processInvalidImages: boolean;
raw?: RawImageInfo;
edits?: AssetEditActionItem[];
};
}
export interface DecodeToBufferOptions extends DecodeImageOptions {
size?: number;
orientation?: ExifOrientation;
}
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
@@ -511,7 +504,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.MemoriesState]: MemoriesState;
}
export type UserPreferences = {
export interface UserPreferences {
albums: {
defaultAssetOrder: AssetOrder;
};
@@ -554,7 +547,7 @@ export type UserPreferences = {
cast: {
gCastEnabled: boolean;
};
};
}
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;

View File

@@ -1,7 +1,5 @@
import { Kysely } from 'kysely';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetFileType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
@@ -12,7 +10,6 @@ import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
import { ImmichFileResponse } from 'src/utils/file';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
@@ -100,162 +97,4 @@ describe(AssetService.name, () => {
});
});
});
describe('viewThumbnail', () => {
it('should return original thumbnail by default when both exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create both original and edited thumbnails
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/original/preview.jpg',
isEdited: false,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/edited/preview.jpg',
isEdited: true,
});
const auth = factory.auth({ user: { id: user.id } });
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW });
expect(result).toBeInstanceOf(ImmichFileResponse);
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
});
it('should return edited thumbnail when edited=true', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create both original and edited thumbnails
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/original/preview.jpg',
isEdited: false,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/edited/preview.jpg',
isEdited: true,
});
const auth = factory.auth({ user: { id: user.id } });
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: true });
expect(result).toBeInstanceOf(ImmichFileResponse);
expect((result as ImmichFileResponse).path).toBe('/edited/preview.jpg');
});
it('should return original thumbnail when edited=false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create both original and edited thumbnails
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/original/preview.jpg',
isEdited: false,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/edited/preview.jpg',
isEdited: true,
});
const auth = factory.auth({ user: { id: user.id } });
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: false });
expect(result).toBeInstanceOf(ImmichFileResponse);
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
});
it('should return original thumbnail when only original exists and edited=false', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create only original thumbnail
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/original/preview.jpg',
isEdited: false,
});
const auth = factory.auth({ user: { id: user.id } });
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: false });
expect(result).toBeInstanceOf(ImmichFileResponse);
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
});
it('should return original thumbnail when only original exists and edited=true', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create only original thumbnail
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: '/original/preview.jpg',
isEdited: false,
});
const auth = factory.auth({ user: { id: user.id } });
const result = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW, edited: true });
expect(result).toBeInstanceOf(ImmichFileResponse);
expect((result as ImmichFileResponse).path).toBe('/original/preview.jpg');
});
it('should work with thumbnail size', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
// Create both original and edited thumbnails
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/original/thumbnail.jpg',
isEdited: false,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Thumbnail,
path: '/edited/thumbnail.jpg',
isEdited: true,
});
const auth = factory.auth({ user: { id: user.id } });
// Test default (should get original)
const resultDefault = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.THUMBNAIL });
expect(resultDefault).toBeInstanceOf(ImmichFileResponse);
expect((resultDefault as ImmichFileResponse).path).toBe('/original/thumbnail.jpg');
// Test edited=true (should get edited)
const resultEdited = await sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true });
expect(resultEdited).toBeInstanceOf(ImmichFileResponse);
expect((resultEdited as ImmichFileResponse).path).toBe('/edited/thumbnail.jpg');
});
});
});

View File

@@ -50,8 +50,5 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertBulkMetadata: vitest.fn(),
deleteMetadataByKey: vitest.fn(),
deleteBulkMetadata: vitest.fn(),
getForOriginal: vitest.fn(),
getForThumbnail: vitest.fn(),
getForVideo: vitest.fn(),
};
};

View File

@@ -1,7 +1,6 @@
import {
Activity,
ApiKey,
AssetFace,
AssetFile,
AuthApiKey,
AuthSharedLink,
@@ -10,16 +9,12 @@ import {
Library,
Memory,
Partner,
Person,
Session,
Stack,
Tag,
User,
UserAdmin,
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import {
AssetFileType,
@@ -28,11 +23,10 @@ import {
AssetVisibility,
MemoryType,
Permission,
SourceType,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@@ -166,18 +160,11 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...dto,
});
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
const ownerId = newUuid();
return {
id: newUuid(),
primaryAssetId: assets?.[0].id ?? newUuid(),
ownerId,
owner: userFactory(owner ?? { id: ownerId }),
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
...stack,
};
};
const stackFactory = () => ({
id: newUuid(),
ownerId: newUuid(),
primaryAssetId: newUuid(),
});
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
@@ -236,43 +223,39 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
};
};
const assetFactory = (
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
) => {
return {
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
};
};
const assetFactory = (asset: Partial<MapAsset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...asset,
});
const activityFactory = (activity: Partial<Activity> = {}) => {
const userId = activity.userId || newUuid();
@@ -408,102 +391,6 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
...file,
});
const exifFactory = (exif: Partial<Exif> = {}) => ({
assetId: newUuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: newDate(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: newDate(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...exif,
});
const tagFactory = (tag: Partial<Tag>): Tag => ({
id: newUuid(),
color: null,
createdAt: newDate(),
parentId: null,
updatedAt: newDate(),
value: `tag-${newUuid()}`,
...tag,
});
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
assetId: newUuid(),
boundingBoxX1: 1,
boundingBoxX2: 2,
boundingBoxY1: 1,
boundingBoxY2: 2,
deletedAt: null,
id: newUuid(),
imageHeight: 420,
imageWidth: 42,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
person: person === null ? null : personFactory(person),
...face,
});
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
switch (edit?.action) {
case AssetEditAction.Crop: {
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
}
case AssetEditAction.Mirror: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
}
case AssetEditAction.Rotate: {
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
}
default: {
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
}
}
};
const personFactory = (person?: Partial<Person>): Person => ({
birthDate: newDate(),
color: null,
createdAt: newDate(),
faceAssetId: null,
id: newUuid(),
isFavorite: false,
isHidden: false,
name: 'person',
ownerId: newUuid(),
thumbnailPath: '/path/to/person/thumbnail.jpg',
updatedAt: newDate(),
updateId: newUuidV7(),
...person,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
@@ -525,11 +412,6 @@ export const factory = {
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
exif: exifFactory,
face: faceFactory,
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,
uuid: newUuid,
date: newDate,
responses: {

View File

@@ -97,7 +97,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.48.0",
"svelte": "5.46.4",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

View File

@@ -74,20 +74,6 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
--vt-duration-default: 250ms;
--vt-duration-hero: 350ms;
--vt-duration-slideshow: 1s;
--vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--vt-viewer-slide-distance: 15%;
--vt-viewer-opacity-start: 0.1;
--vt-viewer-opacity-mid: 0.4;
--vt-viewer-blur-max: 4px;
--vt-viewer-blur-mid: 2px;
}
button:not(:disabled),
@@ -185,318 +171,3 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s flyInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 4;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
background-color: var(--color-black);
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right) {
height: 100dvh;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
background-color: var(--color-black);
opacity: 1 !important;
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(hero) {
animation: var(--vt-duration-hero) fadeOut forwards;
align-content: center;
}
::view-transition-new(hero) {
animation: var(--vt-duration-hero) fadeIn forwards;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutLeft forwards;
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInRight forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
}
::view-transition-old(previous-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
}
::view-transition-new(previous-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
overflow: hidden;
}
@keyframes flyInLeft {
from {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
/* object-position: 25dvw; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
/* Fly out to right */
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: 50dvw 0px; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Reduced motion: when system preference is set */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-name: none;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
animation: var(--vt-duration-default) fadeOut forwards;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: var(--vt-duration-default) fadeIn forwards;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -1,207 +0,0 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import type { ClassValue } from 'svelte/elements';
/**
* Converts a ClassValue to a string suitable for className assignment.
* Handles strings, arrays, and objects similar to how clsx works.
*/
function classValueToString(value: ClassValue | undefined): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((v) => classValueToString(v))
.filter(Boolean)
.join(' ');
}
// Object/dictionary case
return Object.entries(value)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ');
}
export interface ImageLoaderProperties {
imgClass?: ClassValue;
alt?: string;
draggable?: boolean;
role?: string;
style?: string;
title?: string | null;
loading?: 'lazy' | 'eager';
dataAttributes?: Record<string, string>;
}
export interface ImageSourceProperty {
src: string | undefined;
}
export interface ImageLoaderCallbacks {
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
onElementCreated?: (element: HTMLImageElement) => void;
}
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
if (params.dataAttributes) {
for (const [key, value] of Object.entries(params.dataAttributes)) {
img.setAttribute(key, value);
}
}
};
const destroyImageElement = (
imgElement: HTMLImageElement,
currentSrc: string | undefined,
handleLoad: () => void,
handleError: () => void,
) => {
imgElement.removeEventListener('load', handleLoad);
imgElement.removeEventListener('error', handleError);
cancelImageUrl(currentSrc);
imgElement.remove();
};
const createImageElement = (
src: string | undefined,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
onElementCreated?: (imgElement: HTMLImageElement) => void,
) => {
if (!src) {
return undefined;
}
const img = document.createElement('img');
updateImageAttributes(img, properties);
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
onStart?.();
if (src) {
img.src = src;
onElementCreated?.(img);
}
return img;
};
export function loadImage(
src: string,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
) {
let destroyed = false;
const wrapper = (fn: (() => void) | undefined) => () => {
if (destroyed) {
return;
}
fn?.();
};
const wrappedOnLoad = wrapper(onLoad);
const wrappedOnError = wrapper(onError);
const wrappedOnStart = wrapper(onStart);
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
if (!img) {
return () => void 0;
}
return () => {
destroyed = true;
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
};
}
export type LoadImageFunction = typeof loadImage;
/**
* 1. Creates and appends an <img> element to the parent
* 2. Coordinates with service worker before src triggers fetch
* 3. Adds load/error listeners
* 4. Cancels SW request when element is removed from DOM
*/
export function imageLoader(
node: HTMLElement,
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
) {
let currentSrc = params.src;
let currentCallbacks = params;
let imgElement: HTMLImageElement | undefined = undefined;
const handleLoad = () => {
currentCallbacks.onLoad?.();
};
const handleError = () => {
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
};
const handleElementCreated = (img: HTMLImageElement) => {
if (img) {
node.append(img);
currentCallbacks.onElementCreated?.(img);
}
};
const createImage = () => {
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
};
createImage();
return {
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
// If src changed, recreate the image element
if (newParams.src !== currentSrc) {
if (imgElement) {
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
}
currentSrc = newParams.src;
currentCallbacks = newParams;
createImage();
return;
}
currentCallbacks = newParams;
if (!imgElement) {
return;
}
updateImageAttributes(imgElement, newParams);
},
destroy() {
if (imgElement) {
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
}
},
};
}

View File

@@ -1,12 +1,19 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { createZoomImageWheel } from '@zoom-image/core';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
const state = get(photoZoomState);
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: state,
});
const unsubscribes = [
assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
zoomInstance.subscribe(({ state }) => {
photoZoomState.set(state);
}),
];
const stopIfDisabled = (event: Event) => {

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
import type { EventCallback } from '$lib/utils/base-event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: EventCallback<Events, K>;
};
const props: Props = $props();
onMount(() => {
const unsubscribes: Array<() => void> = [];
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
if (!listener) {
continue;
}
unsubscribes.push(assetViewerManager.on(event, listener));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>

View File

@@ -2,15 +2,14 @@
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
};
type Props = Partial<{
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void;
}>;
const props: Props = $props();
const unsubscribes: Array<() => void> = [];
onMount(() => {
const unsubscribes: Array<() => void> = [];
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props];
@@ -21,7 +20,8 @@
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
unsubscribes.push(eventManager.on(...args));
eventManager.on(...args);
unsubscribes.push(() => eventManager.off(...args));
}
return () => {

View File

@@ -37,11 +37,6 @@
name="format"
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.thumbnail.progressive = false;
}
}}
/>
<SettingSelect
@@ -69,15 +64,6 @@
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.thumbnail.progressive}
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -96,11 +82,6 @@
name="format"
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.preview.progressive = false;
}
}}
/>
<SettingSelect
@@ -127,15 +108,6 @@
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.preview.progressive}
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -165,11 +137,6 @@
name="format"
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.fullsize.progressive = false;
}
}}
/>
<SettingInputField
@@ -180,17 +147,6 @@
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.fullsize.progressive}
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
disabled={disabled ||
!configToEdit.image.fullsize.enabled ||
configToEdit.image.fullsize.format === ImageFormat.Webp}
/>
</SettingAccordion>
<div class="mt-4">

View File

@@ -1,5 +1,5 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetThumbnailUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { render } from '@testing-library/svelte';
@@ -7,7 +7,7 @@ vi.mock('$lib/utils');
describe('AlbumCover component', () => {
it('renders an image when the album has a thumbnail', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
const component = render(AlbumCover, {
album: albumFactory.build({
albumName: 'someName',
@@ -21,7 +21,7 @@ describe('AlbumCover component', () => {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' });
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
});
it('renders an image when the album has no thumbnail', () => {

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetThumbnailUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,7 +15,7 @@
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
);
</script>

View File

@@ -13,7 +13,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -114,7 +114,7 @@
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}

View File

@@ -14,7 +14,7 @@ type ActionMap = {
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onAction: () => void;
}
let { onAction }: Props = $props();
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiTune}
aria-label={$t('editor')}
onclick={() => onAction()}
/>

View File

@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
@@ -142,7 +142,7 @@
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetMediaUrl({ id: reaction.assetId })}
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
</a>
@@ -195,7 +195,7 @@
>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetMediaUrl({ id: reaction.assetId })}
src={getAssetThumbnailUrl(reaction.assetId)}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
</a>

View File

@@ -1,277 +0,0 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, untrack, type Snippet } from 'svelte';
interface Props {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
zoomDisabled?: boolean;
imageClass?: string;
container: {
width: number;
height: number;
};
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
transitionName?: string | null | undefined;
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
overlays?: Snippet;
}
let {
imgElement = $bindable<HTMLImageElement | undefined>(),
asset,
sharedLink,
zoomDisabled = false,
imageClass = '',
container,
slideshowState,
slideshowLook,
transitionName,
onImageReady,
onError,
overlays,
}: Props = $props();
let previousLoader = $state<AdaptiveImageLoader>();
let previousAssetId: string | undefined;
let previousSharedLinkId: string | undefined;
const resetZoomState = () => {
assetViewerManager.zoomState = {
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
};
};
const zoomTransform = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY, currentRotation } = assetViewerManager.zoomState;
return `translate3d(${currentPositionX}px, ${currentPositionY}px, 0) scale(${currentZoom}) rotate(${currentRotation}deg)`;
});
const adaptiveImageLoader = $derived.by(() => {
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
return previousLoader!;
}
return untrack(() => {
previousAssetId = asset.id;
previousSharedLinkId = sharedLink?.id;
previousLoader?.destroy();
resetZoomState();
const loader = new AdaptiveImageLoader(asset, sharedLink, {
currentZoomFn: () => assetViewerManager.zoom,
onImageReady,
onError,
});
previousLoader = loader;
return loader;
});
});
onDestroy(() => adaptiveImageLoader.destroy());
const imageDimensions = $derived.by(() => {
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
return { width: asset.width!, height: asset.height! };
}
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
return getDimensions(asset.exifInfo) as { width: number; height: number };
}
return { width: 1, height: 1 };
});
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
const renderDimensions = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
const thumbnailUrl = $derived(loadState.thumbnailUrl);
const previewUrl = $derived(loadState.previewUrl);
const originalUrl = $derived(loadState.originalUrl);
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
const showBrokenAsset = $derived(loadState.hasError);
// Effect: Upgrade to original when user zooms in
$effect(() => {
if (assetViewerManager.zoom > 1 && loadState.quality === 'preview') {
void adaptiveImageLoader.triggerOriginal();
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
// Effect: Synchronize highest quality element as main imgElement
$effect(() => {
imgElement = originalElement ?? previewElement ?? thumbnailElement;
});
</script>
<div class="relative h-full w-full">
<!-- Blurred slideshow background (full viewport) -->
{#if blurredSlideshow}
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
></canvas>
{/if}
<!-- Letterbox regions (empty space around image) -->
<Letterboxes
{transitionName}
{slideshowState}
{slideshowLook}
hasThumbhash={!!asset.thumbhash}
{scaledDimensions}
{container}
/>
<!-- Main image box with transition -->
<div
style:view-transition-name={transitionName}
data-transition-name={transitionName}
class="absolute"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<div style:transform-origin="0px 0px" style:transform={zoomTransform} class="h-full w-full absolute">
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
</div>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={zoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (element) => (thumbnailElement = element),
imgClass: ['absolute h-full', 'w-full'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
{#if showBrokenAsset}
<div class="h-full w-full absolute" style:transform-origin="0px 0px" style:transform={zoomTransform}>
<BrokenAsset class="text-xl h-full w-full" />
</div>
{:else}
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={zoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (element) => (previewElement = element),
imgClass: ['h-full', 'w-full', imageClass],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
>
{@render overlays?.()}
</div>
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={zoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (element) => (originalElement = element),
imgClass: ['h-full', 'w-full', imageClass],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
>
{@render overlays?.()}
</div>
{/if}
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
<div
class="absolute top-0"
use:zoomImageAction={{ disabled: zoomDisabled }}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
</div>
</div>
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

Some files were not shown because too many files have changed in this diff Show More